Calendar: write shared events in the account that holds them #50

Merged
jcoffey-dev merged 1 commits from fix/shared-calendar-writes into main 2026-10-09 22:53:19 +00:00
6 changed files with 274 additions and 35 deletions

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
View File
@@ -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);
+3 -3
View File
@@ -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>
);
+39 -10
View File
@@ -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>
+2 -2
View File
@@ -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) {