Calendar: write shared events in the account that holds them #50
No files matched your search
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { sharedKey, useCalendar, writableSharedCalendars, type SharedCalendar } from "@/store/calendar";
|
||||
import type { Calendar, CalendarEvent, JmapSession } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Writes about an event in a calendar somebody shared go to the account that
|
||||
* holds it (#49).
|
||||
*
|
||||
* JMAP ids only mean something inside their account, and Stalwart's are short,
|
||||
* so the owner's event and one of the reader's own can carry the same id. A
|
||||
* write sent to the reader's account under the owner's id is notFound at best;
|
||||
* at worst it changes or deletes the reader's own, unrelated event. Here both
|
||||
* accounts hold an event "e1", so a write that goes to the wrong one shows.
|
||||
*/
|
||||
|
||||
const OWN = "a1";
|
||||
const OWNER = "b2";
|
||||
|
||||
const event = (over: Partial<CalendarEvent> = {}): CalendarEvent => ({
|
||||
id: "e1",
|
||||
"@type": "Event",
|
||||
uid: "u1",
|
||||
calendarIds: { c1: true },
|
||||
start: "2026-10-12T09:00:00",
|
||||
timeZone: "UTC",
|
||||
duration: "PT1H",
|
||||
...over,
|
||||
}) as unknown as CalendarEvent;
|
||||
|
||||
const calendar = (over: Partial<Calendar> = {}): Calendar => ({
|
||||
id: "c1",
|
||||
name: "Team",
|
||||
isSubscribed: true,
|
||||
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true },
|
||||
...over,
|
||||
}) as unknown as Calendar;
|
||||
|
||||
interface Call { method: string; accountId: string; args: Record<string, unknown> }
|
||||
|
||||
function server() {
|
||||
const calls: Call[] = [];
|
||||
vi.stubGlobal("fetch", 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]) => {
|
||||
const accountId = args.accountId as string;
|
||||
calls.push({ method: name, accountId, args });
|
||||
if (name === "CalendarEvent/set") {
|
||||
return [name, {
|
||||
accountId, oldState: "1", newState: "2",
|
||||
created: args.create ? { e: { id: "new1" } } : undefined,
|
||||
updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])),
|
||||
destroyed: (args.destroy ?? []) as string[],
|
||||
notCreated: {}, notUpdated: {}, notDestroyed: {},
|
||||
}, id];
|
||||
}
|
||||
if (name === "CalendarEvent/get") {
|
||||
// The occurrence lookup asks with "#ids"; answer it from the account asked.
|
||||
const list = id === "g" ? [{ id: "e1-occ", baseEventId: "e1", recurrenceId: "2026-10-12T09:00:00" }] : [event()];
|
||||
return [name, { accountId, state: "1", list, notFound: [] }, id];
|
||||
}
|
||||
return [name, { accountId, 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;
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
const sets = (calls: Call[]) => calls.filter((c) => c.method === "CalendarEvent/set");
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
|
||||
accounts: {},
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useCalendar.setState({
|
||||
accountId: OWN,
|
||||
available: true,
|
||||
calendars: { c1: calendar({ name: "Mine" }) },
|
||||
events: { e1: event({ title: "my own" }) },
|
||||
ranges: {},
|
||||
sharedCalendars: [{ accountId: OWNER, accountName: "Ruth", calendar: calendar() }],
|
||||
sharedEvents: { [sharedKey(OWNER, "e1")]: event({ title: "theirs" }) },
|
||||
sharedRanges: {},
|
||||
identities: [{ id: "id1", name: "Me", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("writes to a shared event", () => {
|
||||
it("delete goes to the owner's account, and the reader's own event of the same id stays", async () => {
|
||||
const calls = server();
|
||||
await useCalendar.getState().destroyEvent(event(), false, "series", OWNER);
|
||||
expect(sets(calls).map((c) => c.accountId)).toEqual([OWNER]);
|
||||
expect(sets(calls)[0]!.args.destroy).toEqual(["e1"]);
|
||||
expect(useCalendar.getState().events.e1).toBeDefined();
|
||||
expect(useCalendar.getState().sharedEvents[sharedKey(OWNER, "e1")]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("an edit goes to the owner's account", async () => {
|
||||
const calls = server();
|
||||
await useCalendar.getState().updateEvent(event(), { title: "moved" }, false, "series", OWNER);
|
||||
expect(sets(calls).map((c) => c.accountId)).toEqual([OWNER]);
|
||||
});
|
||||
|
||||
it("one date of a shared series is looked up and written in the owner's account", async () => {
|
||||
const calls = server();
|
||||
const occ = event({ id: "e1-occ", baseEventId: "e1", recurrenceId: "2026-10-12T09:00:00" });
|
||||
await useCalendar.getState().updateEvent(occ, { title: "just this once" }, false, "occurrence", OWNER);
|
||||
expect(calls.filter((c) => c.method !== "CalendarEvent/set").map((c) => c.accountId)).toEqual([OWNER, OWNER]);
|
||||
expect(sets(calls).map((c) => c.accountId)).toEqual([OWNER]);
|
||||
});
|
||||
|
||||
it("a reply to an invitation in a shared calendar goes to the owner's account", async () => {
|
||||
const calls = server();
|
||||
const invited = event({ participants: { me: { "@type": "Participant", calendarAddress: "mailto:[email protected]", roles: { attendee: true } } } } as Partial<CalendarEvent>);
|
||||
await useCalendar.getState().rsvp(invited, "accepted", undefined, OWNER);
|
||||
expect(sets(calls).map((c) => c.accountId)).toEqual([OWNER]);
|
||||
});
|
||||
|
||||
it("a new event can be created in a shared calendar", async () => {
|
||||
const calls = server();
|
||||
await useCalendar.getState().createEvent({ title: "planning" }, "c1", false, OWNER);
|
||||
expect(sets(calls).map((c) => c.accountId)).toEqual([OWNER]);
|
||||
});
|
||||
|
||||
it("without an account, writes still go to the reader's own", async () => {
|
||||
const calls = server();
|
||||
await useCalendar.getState().destroyEvent(event(), false, "series");
|
||||
expect(sets(calls).map((c) => c.accountId)).toEqual([OWN]);
|
||||
expect(useCalendar.getState().events.e1).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reading a shared event does not put it among the reader's own", async () => {
|
||||
server();
|
||||
useCalendar.setState({ events: {} });
|
||||
await useCalendar.getState().getEvent("e1", OWNER);
|
||||
expect(useCalendar.getState().events).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("instancesIn", () => {
|
||||
it("keys and addresses a shared event by its account, beside the reader's own of the same id", () => {
|
||||
useCalendar.setState({ ranges: { k: ["e1"] }, sharedRanges: { k: [sharedKey(OWNER, "e1")] } });
|
||||
const out = useCalendar.getState().instancesIn(new Date("2026-10-12T00:00:00Z"), new Date("2026-10-13T00:00:00Z"));
|
||||
const mine = out.find((i) => i.event.title === "my own")!;
|
||||
const theirs = out.find((i) => i.event.title === "theirs")!;
|
||||
expect(mine.accountId).toBeUndefined();
|
||||
expect(theirs.accountId).toBe(OWNER);
|
||||
expect(theirs.key).toBe(sharedKey(OWNER, "e1"));
|
||||
expect(theirs.key).not.toBe(mine.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writableSharedCalendars", () => {
|
||||
const shared = (over: Partial<Calendar>, accountId = OWNER): SharedCalendar => ({ accountId, accountName: "Ruth", calendar: calendar(over) });
|
||||
|
||||
it("offers a subscribed calendar the share lets the reader write to", () => {
|
||||
expect(writableSharedCalendars([shared({})], [])).toHaveLength(1);
|
||||
});
|
||||
it("leaves out a read-only share", () => {
|
||||
const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false } as Calendar["myRights"];
|
||||
expect(writableSharedCalendars([shared({ myRights: readOnly })], [])).toEqual([]);
|
||||
});
|
||||
it("leaves out one that is reachable but neither subscribed nor added", () => {
|
||||
expect(writableSharedCalendars([shared({ isSubscribed: false })], [])).toEqual([]);
|
||||
expect(writableSharedCalendars([shared({ isSubscribed: false })], [sharedKey(OWNER, "c1")])).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+50
-17
@@ -18,6 +18,13 @@ export interface EventInstance {
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
calendar: Calendar | undefined;
|
||||
/**
|
||||
* The account the event lives in, for one from a calendar somebody shared;
|
||||
* absent for the reader's own. Event ids only mean something inside their
|
||||
* account, so every write about this event goes there, never to the
|
||||
* reader's own account under the owner's id (#49).
|
||||
*/
|
||||
accountId?: Id;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -229,6 +236,18 @@ export interface SharedCalendar {
|
||||
/** Shared events are keyed by account too: ids only differ within an account. */
|
||||
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`;
|
||||
|
||||
/**
|
||||
* Shared calendars a new event can go into: the ones drawn beside the
|
||||
* reader's own (subscribed, or added here -- see `instancesIn`) whose share
|
||||
* lets them write.
|
||||
*/
|
||||
export function writableSharedCalendars(shared: SharedCalendar[], addedShares: string[]): SharedCalendar[] {
|
||||
const added = new Set(addedShares);
|
||||
return shared.filter((c) =>
|
||||
(c.calendar.isSubscribed || added.has(sharedKey(c.accountId, c.calendar.id))) &&
|
||||
(c.calendar.myRights.mayWriteAll || c.calendar.myRights.mayWriteOwn));
|
||||
}
|
||||
|
||||
/**
|
||||
* An event begun outside the calendar -- from a message, so far.
|
||||
*
|
||||
@@ -282,12 +301,16 @@ interface CalendarState {
|
||||
instancesIn(start: Date, end: Date): EventInstance[];
|
||||
/** Re-fetch every subscribed calendar. */
|
||||
refreshSubscriptions(): Promise<void>;
|
||||
getEvent(id: Id): Promise<CalendarEvent | null>;
|
||||
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
||||
/*
|
||||
* The event calls take the account the event lives in -- `EventInstance`'s
|
||||
* `accountId` -- and default to the reader's own. See `EventInstance`.
|
||||
*/
|
||||
getEvent(id: Id, accountId?: Id): Promise<CalendarEvent | null>;
|
||||
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean, accountId?: Id): Promise<Id>;
|
||||
/** Returns the properties that had to be left to the series, if any. */
|
||||
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope): Promise<string[]>;
|
||||
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise<void>;
|
||||
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
||||
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope, accountId?: Id): Promise<string[]>;
|
||||
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope, accountId?: Id): Promise<void>;
|
||||
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string, accountId?: Id): Promise<void>;
|
||||
createCalendar(data: Partial<Calendar>): Promise<Id>;
|
||||
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
|
||||
destroyCalendar(id: Id): Promise<void>;
|
||||
@@ -692,23 +715,27 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
if (calId && !theirs[calId]) continue;
|
||||
const inst = toInstance(e, theirs);
|
||||
if (!inst) continue;
|
||||
// Keyed and addressed by account: the owner's ids can equal the reader's.
|
||||
inst.key = k;
|
||||
inst.accountId = accountId;
|
||||
if (inst.end > start && inst.start < end) out.push(inst);
|
||||
}
|
||||
out.sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
|
||||
return [...out, ...birthdays];
|
||||
},
|
||||
|
||||
async getEvent(id) {
|
||||
const accountId = get().accountId;
|
||||
async getEvent(id, owner) {
|
||||
const accountId = owner ?? get().accountId;
|
||||
if (!accountId) return null;
|
||||
const res = await client.call<GetResponse<CalendarEvent>>("CalendarEvent/get", { accountId, ids: [id], properties: EVENT_PROPS });
|
||||
const e = res.list[0];
|
||||
if (e) set((s) => ({ events: { ...s.events, [e.id]: e } }));
|
||||
// Only the reader's own go into `events`; a shared one is keyed by account.
|
||||
if (e && accountId === get().accountId) set((s) => ({ events: { ...s.events, [e.id]: e } }));
|
||||
return e ?? null;
|
||||
},
|
||||
|
||||
async createEvent(event, calendarId, sendInvites) {
|
||||
const accountId = get().accountId!;
|
||||
async createEvent(event, calendarId, sendInvites, owner) {
|
||||
const accountId = owner ?? get().accountId!;
|
||||
const obj = { "@type": "Event", uid: crypto.randomUUID(), ...event, calendarIds: { [calendarId]: true } };
|
||||
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: { e: obj }, sendSchedulingMessages: sendInvites });
|
||||
const err = res.notCreated?.e;
|
||||
@@ -717,7 +744,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
return res.created!.e!.id;
|
||||
},
|
||||
|
||||
async updateEvent(event, patch, sendInvites, scope) {
|
||||
async updateEvent(event, patch, sendInvites, scope, owner) {
|
||||
/*
|
||||
* A derived birthday has no server-side existence, so there is nothing to
|
||||
* write and an id that would mean nothing if sent. The UI already keeps
|
||||
@@ -726,7 +753,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
* it.
|
||||
*/
|
||||
if (isBirthdayEvent(event.id) || isSubscriptionEvent(event.id)) return [];
|
||||
const accountId = get().accountId!;
|
||||
const accountId = owner ?? get().accountId!;
|
||||
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
|
||||
// An occurrence takes less than the series does, and says so about only
|
||||
// half of it. Narrow the patch here rather than posting it hopefully.
|
||||
@@ -739,7 +766,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
return dropped;
|
||||
},
|
||||
|
||||
async destroyEvent(event, sendInvites, scope) {
|
||||
async destroyEvent(event, sendInvites, scope, owner) {
|
||||
/*
|
||||
* A derived birthday has no server-side existence, so there is nothing to
|
||||
* write and an id that would mean nothing if sent. The UI already keeps
|
||||
@@ -748,15 +775,21 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
* it.
|
||||
*/
|
||||
if (isBirthdayEvent(event.id) || isSubscriptionEvent(event.id)) return;
|
||||
const accountId = get().accountId!;
|
||||
const accountId = owner ?? get().accountId!;
|
||||
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
|
||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
|
||||
const err = res.notDestroyed?.[id];
|
||||
if (err) throw new CalendarSetError(err);
|
||||
set((s) => {
|
||||
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.
|
||||
if (accountId !== s.accountId) {
|
||||
const sharedEvents = { ...s.sharedEvents };
|
||||
delete sharedEvents[sharedKey(accountId, id)];
|
||||
if (scope === "occurrence") delete sharedEvents[sharedKey(accountId, event.id)];
|
||||
return { sharedEvents };
|
||||
}
|
||||
const events = { ...s.events };
|
||||
delete events[id];
|
||||
if (scope === "occurrence") delete events[event.id];
|
||||
return { events };
|
||||
@@ -764,7 +797,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
get().invalidate();
|
||||
},
|
||||
|
||||
async rsvp(event, status, comment) {
|
||||
async rsvp(event, status, comment, owner) {
|
||||
const mine = myParticipantKeys(event, get().identities);
|
||||
if (!mine.length) throw new Error("You are not a participant of this event");
|
||||
const patch: Record<string, unknown> = {};
|
||||
@@ -777,7 +810,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
// 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");
|
||||
await get().updateEvent(event, patch, true, "series", owner);
|
||||
},
|
||||
|
||||
async createCalendar(data) {
|
||||
|
||||
@@ -69,7 +69,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
const scope = await askEditScope(ev);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, p, false, s));
|
||||
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, p, false, s, inst.accountId));
|
||||
if (!dropped) return;
|
||||
// A per-occurrence change can be accepted in part. Say which part.
|
||||
toast.success(droppedMessage(dropped) ?? (scope === "occurrence" ? `${msg} for this date` : msg));
|
||||
@@ -85,7 +85,9 @@ 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: t("Copy of {title}", { title: ev.title ?? t("event") }), participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
|
||||
// The copy goes into the same calendar, so into the same account.
|
||||
const sameCal = Object.keys(calendarIds)[0];
|
||||
await cal.createEvent({ ...rest, title: t("Copy of {title}", { title: ev.title ?? t("event") }), participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial<CalendarEvent>, sameCal ?? Object.keys(cal.calendars)[0]!, false, sameCal ? inst.accountId : undefined);
|
||||
toast.success(t("Event duplicated"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
@@ -101,7 +103,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
}
|
||||
if (!scope) return;
|
||||
try {
|
||||
await runScoped(scope, (s) => cal.destroyEvent(ev, participants > 1, s));
|
||||
await runScoped(scope, (s) => cal.destroyEvent(ev, participants > 1, s, inst.accountId));
|
||||
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
|
||||
@@ -130,7 +130,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
||||
const scope = await askEditScope(ev);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const dropped = await runScoped(scope, (sc) => cal.updateEvent(ev, { ...patch }, false, sc));
|
||||
const dropped = await runScoped(scope, (sc) => cal.updateEvent(ev, { ...patch }, false, sc, inst.accountId));
|
||||
if (!dropped) return;
|
||||
const message = droppedMessage(dropped);
|
||||
if (message) toast.success(message);
|
||||
@@ -217,9 +217,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
||||
{effectiveView === "month" && <MonthView onDragCommit={(i, patch) => void commitDrag(i, patch)} anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />}
|
||||
{(effectiveView === "week" || effectiveView === "day") && <TimeGrid onDragCommit={(i, patch) => void commitDrag(i, patch)} days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
|
||||
{effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
|
||||
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
|
||||
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, accountId: inst.accountId, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
|
||||
{isMobile && <button className="fab" aria-label={translate("New event")} onClick={() => openNew()}><Plus size={24} /></button>}
|
||||
{popover && <EventPopover inst={popover.inst} anchor={popover.anchor} onClose={() => setPopover(null)} onEdit={() => { setEditor({ event: popover.inst.event, start: popover.inst.start, end: popover.inst.end, allDay: popover.inst.allDay }); setPopover(null); }} />}
|
||||
{popover && <EventPopover inst={popover.inst} anchor={popover.anchor} onClose={() => setPopover(null)} onEdit={() => { setEditor({ event: popover.inst.event, accountId: popover.inst.accountId, start: popover.inst.start, end: popover.inst.end, allDay: popover.inst.allDay }); setPopover(null); }} />}
|
||||
{editor && <EventEditor init={editor} onClose={() => setEditor(null)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 type { BusyPeriod, Calendar, CalendarEvent, EmailAddress, Id, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, sharedKey, writableSharedCalendars, type EventScope } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useSession } from "@/store/session";
|
||||
import { CAP } from "@/jmap/client";
|
||||
@@ -21,6 +21,8 @@ import { plural, t as translate } from "@/lib/i18n";
|
||||
|
||||
export interface EditorInit {
|
||||
event?: CalendarEvent;
|
||||
/** The account `event` lives in when it is from a shared calendar. See `EventInstance`. */
|
||||
accountId?: Id;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
@@ -50,6 +52,14 @@ const BUSY_LABEL: Record<"confirmed" | "tentative" | "unavailable", string> = {
|
||||
* success. Both halves are reasons not to send them — the second more so,
|
||||
* because nothing would say it had happened.
|
||||
*/
|
||||
/** A calendar the editor can save into, and the account it is in (absent: the reader's own). */
|
||||
interface CalendarTarget {
|
||||
token: string;
|
||||
accountId?: Id;
|
||||
calendar: Calendar;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const OCCURRENCE_OMIT = new Set(["useDefaultAlerts", "calendarIds", "recurrenceRule", "privacy", "organizerCalendarAddress"]);
|
||||
|
||||
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
|
||||
@@ -92,7 +102,7 @@ export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () =
|
||||
if (!chosen) { onClose(); return; }
|
||||
setScope(chosen);
|
||||
if (chosen === "occurrence") setBase(ev);
|
||||
else void cal.getEvent(ev.baseEventId!).then(setBase);
|
||||
else void cal.getEvent(ev.baseEventId!, init.accountId).then(setBase);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [init.event?.id]);
|
||||
@@ -107,14 +117,33 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
||||
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 addedShares = useSettings((s) => s.settings.addedShares);
|
||||
/*
|
||||
* Where the event can be saved: the reader's own writable calendars, then
|
||||
* shared ones they may write to, named with whose they are. An existing
|
||||
* event stays in its account -- JMAP has no moving an event between
|
||||
* accounts -- so editing offers only that account's calendars.
|
||||
*/
|
||||
const targets: CalendarTarget[] = [
|
||||
...Object.values(cal.calendars)
|
||||
.filter((c) => c.myRights.mayWriteAll || c.myRights.mayWriteOwn)
|
||||
.map((c): CalendarTarget => ({ token: c.id, calendar: c, label: c.name })),
|
||||
...writableSharedCalendars(cal.sharedCalendars, addedShares)
|
||||
.map((s): CalendarTarget => ({ token: sharedKey(s.accountId, s.calendar.id), accountId: s.accountId, calendar: s.calendar, label: `${s.calendar.name} (${s.accountName})` })),
|
||||
].filter((x) => !ev || x.accountId === init.accountId);
|
||||
const own = targets.filter((x) => !x.accountId);
|
||||
const evCal = ev ? Object.keys(ev.calendarIds)[0] : undefined;
|
||||
const initialTarget = evCal
|
||||
? (init.accountId ? sharedKey(init.accountId, evCal) : evCal)
|
||||
: (own.find((x) => x.calendar.isDefault)?.token ?? own[0]?.token ?? targets[0]?.token);
|
||||
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 [target, setTarget] = useState(initialTarget ?? "");
|
||||
const chosen = targets.find((x) => x.token === target);
|
||||
const calendarId = chosen?.calendar.id ?? (evCal && target === initialTarget ? evCal : "");
|
||||
const [allDay, setAllDay] = useState(ev ? Boolean(ev.showWithoutTime) : init.allDay);
|
||||
const [start, setStart] = useState(baseStart);
|
||||
const [end, setEnd] = useState(baseEnd);
|
||||
@@ -313,13 +342,13 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
||||
const patch: Record<string, unknown> = {};
|
||||
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));
|
||||
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, patch, invites, s, init.accountId));
|
||||
if (!dropped) { setBusy(false); return; }
|
||||
toast.success(droppedMessage(dropped) ?? (oneDate ? "This occurrence updated" : "Event updated"));
|
||||
} else {
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
|
||||
await cal.createEvent(clean as Partial<CalendarEvent>, calendarId, invites);
|
||||
await cal.createEvent(clean as Partial<CalendarEvent>, calendarId, invites, chosen?.accountId);
|
||||
toast.success(invites ? "Event created and invitations sent" : "Event created");
|
||||
}
|
||||
onClose();
|
||||
@@ -408,8 +437,8 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
||||
)}
|
||||
<div className="field-row">
|
||||
<div className="field"><label>{translate("Calendar")}</label>
|
||||
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? translate("An occurrence cannot be moved to another calendar on its own") : undefined} onChange={(e) => setCalendarId(e.target.value)}>
|
||||
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
<select className="select" value={target} disabled={oneDate} title={oneDate ? translate("An occurrence cannot be moved to another calendar on its own") : undefined} onChange={(e) => setTarget(e.target.value)}>
|
||||
{targets.map((x) => <option key={x.token} value={x.token}>{x.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>{translate("Location")}</label><input className="input" value={location} onChange={(e) => setLocation(e.target.value)} placeholder={translate("Add location")} /></div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
||||
if (!scope) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await runScoped(scope, (s) => cal.destroyEvent(ev, participants.length > 1, s));
|
||||
await runScoped(scope, (s) => cal.destroyEvent(ev, participants.length > 1, s, inst.accountId));
|
||||
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -55,7 +55,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
||||
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await cal.rsvp(ev, status);
|
||||
await cal.rsvp(ev, status, undefined, inst.accountId);
|
||||
toast.success(t("Response sent"));
|
||||
onClose();
|
||||
} catch (err) {
|
||||
|
||||
Reference in new issue
Block a user