Resolve the base event id in the calendar store, not at the call sites
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`.
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CAP, client } from "@/jmap/client";
|
||||||
|
import { eventIdForScope, isOccurrence, useCalendar } from "@/store/calendar";
|
||||||
|
import type { CalendarEvent, JmapSession } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Through 0.16.19 the server caught a synthetic id for us: `CalendarEvent/set`
|
||||||
|
* refused one outright, so a mutation aimed at the wrong id of an expanded
|
||||||
|
* occurrence arrived as a toast rather than as data loss.
|
||||||
|
*
|
||||||
|
* 0.16.20 accepts it and writes a `recurrenceOverrides` entry instead — a
|
||||||
|
* destroy that meant the series removes one date and reports success, under a
|
||||||
|
* dialog that said "Delete all occurrences?". The resolution therefore lives in
|
||||||
|
* the store behind a required `scope`, and these tests are what stops it
|
||||||
|
* drifting back out to the callers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The shape a live 0.16.19 returns for one occurrence of a weekly series. */
|
||||||
|
const OCCURRENCE: CalendarEvent = {
|
||||||
|
id: "iaaaaas",
|
||||||
|
baseEventId: "i",
|
||||||
|
"@type": "Event",
|
||||||
|
uid: "u1",
|
||||||
|
calendarIds: { c1: true },
|
||||||
|
start: "2026-09-02T09:00:00",
|
||||||
|
duration: "PT30M",
|
||||||
|
recurrenceId: "2026-09-02T09:00:00",
|
||||||
|
participants: {
|
||||||
|
me: { "@type": "Participant", calendarAddress: "mailto:[email protected]", participationStatus: "needs-action", roles: { attendee: true } },
|
||||||
|
},
|
||||||
|
} as unknown as CalendarEvent;
|
||||||
|
|
||||||
|
/** A one-off, which an expanded query still hands back with a base of its own. */
|
||||||
|
const ONE_OFF: CalendarEvent = { ...OCCURRENCE, id: "eaaaaai", baseEventId: "i", recurrenceId: undefined } as unknown as CalendarEvent;
|
||||||
|
|
||||||
|
/** A master, fetched by id rather than expanded. */
|
||||||
|
const MASTER: CalendarEvent = { ...OCCURRENCE, id: "i", baseEventId: undefined, recurrenceId: undefined } as unknown as CalendarEvent;
|
||||||
|
|
||||||
|
interface SetCall { update?: Record<string, unknown>; destroy?: string[] }
|
||||||
|
|
||||||
|
function server() {
|
||||||
|
const calls: SetCall[] = [];
|
||||||
|
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||||
|
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||||
|
if (name === "CalendarEvent/set") {
|
||||||
|
calls.push({ update: args.update as Record<string, unknown>, destroy: args.destroy as string[] });
|
||||||
|
return [name, {
|
||||||
|
accountId: "a1", oldState: "1", newState: "2",
|
||||||
|
updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])),
|
||||||
|
destroyed: (args.destroy ?? []) as string[],
|
||||||
|
notUpdated: {}, notDestroyed: {},
|
||||||
|
}, id];
|
||||||
|
}
|
||||||
|
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||||
|
});
|
||||||
|
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client.session = {
|
||||||
|
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
|
||||||
|
accounts: {},
|
||||||
|
primaryAccounts: {},
|
||||||
|
state: "s1",
|
||||||
|
} as unknown as JmapSession;
|
||||||
|
useCalendar.setState({
|
||||||
|
accountId: "a1",
|
||||||
|
available: true,
|
||||||
|
calendars: {},
|
||||||
|
events: { [OCCURRENCE.id]: OCCURRENCE },
|
||||||
|
ranges: {},
|
||||||
|
identities: [{ id: "id1", name: "Me", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("eventIdForScope", () => {
|
||||||
|
it("walks an occurrence up to its master for the series", () => {
|
||||||
|
expect(eventIdForScope(OCCURRENCE, "series")).toBe("i");
|
||||||
|
});
|
||||||
|
it("sends the instance as it came for a single occurrence", () => {
|
||||||
|
expect(eventIdForScope(OCCURRENCE, "occurrence")).toBe("iaaaaas");
|
||||||
|
});
|
||||||
|
it("resolves a master to itself under either scope", () => {
|
||||||
|
expect(eventIdForScope(MASTER, "series")).toBe("i");
|
||||||
|
expect(eventIdForScope(MASTER, "occurrence")).toBe("i");
|
||||||
|
});
|
||||||
|
it("treats a one-off's synthetic id as a series id, because its base is real", () => {
|
||||||
|
// An expanded query gives a one-off an instance id over a different base.
|
||||||
|
// Stalwart resolves a synthetic id on a component that is neither recurrent
|
||||||
|
// nor an override back to the base event, so both scopes are safe here —
|
||||||
|
// but only `series` sends the id that is unambiguously the event.
|
||||||
|
expect(eventIdForScope(ONE_OFF, "series")).toBe("i");
|
||||||
|
expect(isOccurrence(ONE_OFF)).toBe(true);
|
||||||
|
});
|
||||||
|
it("does not call a master an occurrence", () => {
|
||||||
|
expect(isOccurrence(MASTER)).toBe(false);
|
||||||
|
expect(isOccurrence({ ...MASTER, baseEventId: "i" } as CalendarEvent)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("destroyEvent", () => {
|
||||||
|
it("sends the master id for a series, never the synthetic one", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "series");
|
||||||
|
expect(calls[0]!.destroy).toEqual(["i"]);
|
||||||
|
expect(calls[0]!.destroy).not.toContain("iaaaaas");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the synthetic id for a single occurrence", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
|
||||||
|
expect(calls[0]!.destroy).toEqual(["iaaaaas"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the occurrence from the cache without evicting the master", async () => {
|
||||||
|
server();
|
||||||
|
useCalendar.setState({ events: { i: MASTER, iaaaaas: OCCURRENCE } });
|
||||||
|
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
|
||||||
|
expect(useCalendar.getState().events.iaaaaas).toBeUndefined();
|
||||||
|
expect(useCalendar.getState().events.i).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEvent", () => {
|
||||||
|
it("patches the master for a series", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "series");
|
||||||
|
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches the instance for a single occurrence", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "occurrence");
|
||||||
|
expect(Object.keys(calls[0]!.update!)).toEqual(["iaaaaas"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rsvp", () => {
|
||||||
|
it("answers for the series even when handed an occurrence", async () => {
|
||||||
|
// The patch itself survives either scope: `participationStatus` is one of
|
||||||
|
// the pointers 0.16.20 allows on an occurrence, so an RSVP aimed at an
|
||||||
|
// instance would quietly mean "only that day" and nothing would say so.
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().rsvp(OCCURRENCE, "accepted");
|
||||||
|
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
|
||||||
|
expect(calls[0]!.update!.i).toEqual({ "participants/me/participationStatus": "accepted" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the signed-in identity is not a participant", async () => {
|
||||||
|
server();
|
||||||
|
useCalendar.setState({ identities: [{ id: "id2", name: "Someone", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }] });
|
||||||
|
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
+63
-11
@@ -46,6 +46,43 @@ export const CALENDAR_PROPS = [
|
|||||||
"myRights",
|
"myRights",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of an event's two ids a mutation means.
|
||||||
|
*
|
||||||
|
* `CalendarEvent/query` runs with `expandRecurrences`, so an occurrence arrives
|
||||||
|
* carrying a synthetic `id` of its own *and* a `baseEventId` pointing at the
|
||||||
|
* master it was expanded from. Sending one where the other was meant is not a
|
||||||
|
* distinction the server will make for us:
|
||||||
|
*
|
||||||
|
* - Through 0.16.19 a synthetic id was refused outright — *"Updating synthetic
|
||||||
|
* ids is not yet supported"* — so a slip was loud and arrived as a toast.
|
||||||
|
* - 0.16.20 accepts it, and writes a `recurrenceOverrides` entry instead. A
|
||||||
|
* destroy that meant the series now removes one date and reports success,
|
||||||
|
* under a dialog that said "Delete all occurrences?".
|
||||||
|
*
|
||||||
|
* So the choice is named and required rather than left to each caller to
|
||||||
|
* remember a `??`. There is exactly one place that turns an event into an id,
|
||||||
|
* and it is below.
|
||||||
|
*/
|
||||||
|
export type EventScope = "series" | "occurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id to send for `scope`.
|
||||||
|
*
|
||||||
|
* `series` walks up to the master; `occurrence` sends the instance as it came.
|
||||||
|
* A one-off is safe either way — it has a synthetic id like everything an
|
||||||
|
* expanded query returns, and Stalwart resolves a synthetic id on a component
|
||||||
|
* that is neither recurrent nor an override back to the base event itself.
|
||||||
|
*/
|
||||||
|
export function eventIdForScope(event: CalendarEvent, scope: EventScope): Id {
|
||||||
|
return scope === "series" ? (event.baseEventId ?? event.id) : event.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this object is an expanded occurrence rather than a master. */
|
||||||
|
export function isOccurrence(event: CalendarEvent): boolean {
|
||||||
|
return event.baseEventId != null && event.baseEventId !== event.id;
|
||||||
|
}
|
||||||
|
|
||||||
/** A calendar somebody else shared, and the account it lives in. */
|
/** A calendar somebody else shared, and the account it lives in. */
|
||||||
export interface SharedCalendar {
|
export interface SharedCalendar {
|
||||||
accountId: Id;
|
accountId: Id;
|
||||||
@@ -85,9 +122,9 @@ interface CalendarState {
|
|||||||
instancesIn(start: Date, end: Date): EventInstance[];
|
instancesIn(start: Date, end: Date): EventInstance[];
|
||||||
getEvent(id: Id): Promise<CalendarEvent | null>;
|
getEvent(id: Id): Promise<CalendarEvent | null>;
|
||||||
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
||||||
updateEvent(id: Id, patch: Record<string, unknown>, sendInvites: boolean): Promise<void>;
|
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope): Promise<void>;
|
||||||
destroyEvent(id: Id, sendInvites: boolean): Promise<void>;
|
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise<void>;
|
||||||
rsvp(id: Id, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
||||||
createCalendar(data: Partial<Calendar>): Promise<Id>;
|
createCalendar(data: Partial<Calendar>): Promise<Id>;
|
||||||
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
|
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
|
||||||
destroyCalendar(id: Id): Promise<void>;
|
destroyCalendar(id: Id): Promise<void>;
|
||||||
@@ -359,39 +396,46 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
return res.created!.e!.id;
|
return res.created!.e!.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateEvent(id, patch, sendInvites) {
|
async updateEvent(event, patch, sendInvites, scope) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
|
const id = eventIdForScope(event, scope);
|
||||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
|
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
|
||||||
const err = res.notUpdated?.[id];
|
const err = res.notUpdated?.[id];
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
get().invalidate();
|
get().invalidate();
|
||||||
},
|
},
|
||||||
|
|
||||||
async destroyEvent(id, sendInvites) {
|
async destroyEvent(event, sendInvites, scope) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
|
const id = eventIdForScope(event, scope);
|
||||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
|
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
|
||||||
const err = res.notDestroyed?.[id];
|
const err = res.notDestroyed?.[id];
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const events = { ...s.events };
|
const events = { ...s.events };
|
||||||
|
// Drop both ids: the one that was sent, and the object as the caller
|
||||||
|
// held it. An occurrence destroy leaves the master alone on purpose.
|
||||||
delete events[id];
|
delete events[id];
|
||||||
|
if (scope === "occurrence") delete events[event.id];
|
||||||
return { events };
|
return { events };
|
||||||
});
|
});
|
||||||
get().invalidate();
|
get().invalidate();
|
||||||
},
|
},
|
||||||
|
|
||||||
async rsvp(id, status, comment) {
|
async rsvp(event, status, comment) {
|
||||||
const ev = get().events[id] ?? (await get().getEvent(id));
|
const mine = myParticipantKeys(event, get().identities);
|
||||||
if (!ev) throw new Error("Event not found");
|
|
||||||
id = ev.baseEventId ?? id;
|
|
||||||
const mine = myParticipantKeys(ev, get().identities);
|
|
||||||
if (!mine.length) throw new Error("You are not a participant of this event");
|
if (!mine.length) throw new Error("You are not a participant of this event");
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
for (const k of mine) {
|
for (const k of mine) {
|
||||||
patch[`participants/${k}/participationStatus`] = status;
|
patch[`participants/${k}/participationStatus`] = status;
|
||||||
if (comment) patch[`participants/${k}/participationComment`] = comment;
|
if (comment) patch[`participants/${k}/participationComment`] = comment;
|
||||||
}
|
}
|
||||||
await get().updateEvent(id, patch, true);
|
// Answering for the series, not for one date. The patch itself survives
|
||||||
|
// either scope -- `participants/{key}/participationStatus` is one of the
|
||||||
|
// pointers 0.16.20 allows on an occurrence -- so this would silently mean
|
||||||
|
// "only that day" if it were aimed at an instance. Accepting an invitation
|
||||||
|
// means accepting the series.
|
||||||
|
await get().updateEvent(event, patch, true, "series");
|
||||||
},
|
},
|
||||||
|
|
||||||
async createCalendar(data) {
|
async createCalendar(data) {
|
||||||
@@ -436,6 +480,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
return res.list ?? [];
|
return res.list ?? [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event with this uid, as a master rather than an occurrence.
|
||||||
|
*
|
||||||
|
* The query deliberately omits `expandRecurrences`, so what comes back is the
|
||||||
|
* stored event and `id` is a real id. Callers rely on that — `InviteCard`
|
||||||
|
* removes a cancelled event by handing this straight to `destroyEvent` — so
|
||||||
|
* it is a property of this method, not an accident of the default.
|
||||||
|
*/
|
||||||
async findByUid(uid) {
|
async findByUid(uid) {
|
||||||
const accountId = get().accountId;
|
const accountId = get().accountId;
|
||||||
if (!accountId) return null;
|
if (!accountId) return null;
|
||||||
|
|||||||
@@ -60,14 +60,13 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
|
|
||||||
const { inst } = ctx;
|
const { inst } = ctx;
|
||||||
const ev = inst.event;
|
const ev = inst.event;
|
||||||
const baseId = ev.baseEventId ?? ev.id;
|
|
||||||
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
|
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
|
||||||
const currentCat = categoryOf(ev, categories);
|
const currentCat = categoryOf(ev, categories);
|
||||||
const participants = Object.keys(ev.participants ?? {}).length;
|
const participants = Object.keys(ev.participants ?? {}).length;
|
||||||
|
|
||||||
const patch = async (p: Record<string, unknown>, msg: string) => {
|
const patch = async (p: Record<string, unknown>, msg: string) => {
|
||||||
try {
|
try {
|
||||||
await cal.updateEvent(baseId, p, false);
|
await cal.updateEvent(ev, p, false, "series");
|
||||||
toast.success(msg);
|
toast.success(msg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
@@ -92,7 +91,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
const recurring = isRecurring(ev);
|
const recurring = isRecurring(ev);
|
||||||
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
|
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await cal.destroyEvent(baseId, participants > 1);
|
await cal.destroyEvent(ev, participants > 1, "series");
|
||||||
toast.success("Event deleted");
|
toast.success("Event deleted");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
|
|||||||
@@ -178,7 +178,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
|
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
|
||||||
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
||||||
await cal.updateEvent(ev.id, patch, invites);
|
// `ev` is the master: EventEditor resolves `baseEventId` when it opens
|
||||||
|
// on an occurrence, so the whole series is what this form edits.
|
||||||
|
await cal.updateEvent(ev, patch, invites, "series");
|
||||||
toast.success("Event updated");
|
toast.success("Event updated");
|
||||||
} else {
|
} else {
|
||||||
const clean: Record<string, unknown> = {};
|
const clean: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
|||||||
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
|
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 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 canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
|
||||||
const baseId = ev.baseEventId ?? ev.id;
|
|
||||||
const location = Object.values(ev.locations ?? {})[0];
|
const location = Object.values(ev.locations ?? {})[0];
|
||||||
const vloc = Object.values(ev.virtualLocations ?? {})[0];
|
const vloc = Object.values(ev.virtualLocations ?? {})[0];
|
||||||
const alerts = Object.values(ev.alerts ?? {});
|
const alerts = Object.values(ev.alerts ?? {});
|
||||||
@@ -34,7 +33,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await cal.destroyEvent(baseId, participants.length > 1);
|
await cal.destroyEvent(ev, participants.length > 1, "series");
|
||||||
toast.success("Event deleted");
|
toast.success("Event deleted");
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -47,7 +46,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
|||||||
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
|
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await cal.rsvp(baseId, status);
|
await cal.rsvp(ev, status);
|
||||||
toast.success("Response sent");
|
toast.success("Response sent");
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
|||||||
target = await cal.getEvent(id);
|
target = await cal.getEvent(id);
|
||||||
}
|
}
|
||||||
if (!target) throw new Error("Could not add the event to your calendar");
|
if (!target) throw new Error("Could not add the event to your calendar");
|
||||||
await cal.rsvp(target.id, status);
|
await cal.rsvp(target, status);
|
||||||
setExisting(await cal.getEvent(target.id));
|
setExisting(await cal.getEvent(target.id));
|
||||||
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
|
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -115,7 +115,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
|||||||
)}
|
)}
|
||||||
{method === "CANCEL" && existing && (
|
{method === "CANCEL" && existing && (
|
||||||
<div className="rsvp">
|
<div className="rsvp">
|
||||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">{email.id}</span>
|
<span className="sr-only">{email.id}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user