diff --git a/web/src/store/__tests__/shared-calendar-writes.test.ts b/web/src/store/__tests__/shared-calendar-writes.test.ts new file mode 100644 index 0000000..d95bc56 --- /dev/null +++ b/web/src/store/__tests__/shared-calendar-writes.test.ts @@ -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 => ({ + 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 => ({ + 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 } + +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][] }; + 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:me@example.org", 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:me@example.org", roles: { attendee: true } } } } as Partial); + 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, 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); + }); +}); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 2ea0e93..675e961 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -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; - getEvent(id: Id): Promise; - createEvent(event: Partial, calendarId: Id, sendInvites: boolean): Promise; + /* + * 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; + createEvent(event: Partial, calendarId: Id, sendInvites: boolean, accountId?: Id): Promise; /** Returns the properties that had to be left to the series, if any. */ - updateEvent(event: CalendarEvent, patch: Record, sendInvites: boolean, scope: EventScope): Promise; - destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise; - rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise; + updateEvent(event: CalendarEvent, patch: Record, sendInvites: boolean, scope: EventScope, accountId?: Id): Promise; + destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope, accountId?: Id): Promise; + rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string, accountId?: Id): Promise; createCalendar(data: Partial): Promise; updateCalendar(id: Id, patch: Partial): Promise; destroyCalendar(id: Id): Promise; @@ -693,23 +716,27 @@ export const useCalendar = create((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>("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>("CalendarEvent/set", { accountId, create: { e: obj }, sendSchedulingMessages: sendInvites }); const err = res.notCreated?.e; @@ -718,7 +745,7 @@ export const useCalendar = create((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 @@ -727,7 +754,7 @@ export const useCalendar = create((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. @@ -740,7 +767,7 @@ export const useCalendar = create((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 @@ -749,15 +776,21 @@ export const useCalendar = create((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("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 }; @@ -765,7 +798,7 @@ export const useCalendar = create((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 = {}; @@ -778,7 +811,7 @@ export const useCalendar = create((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) { diff --git a/web/src/views/calendar/CalendarContextMenu.tsx b/web/src/views/calendar/CalendarContextMenu.tsx index 90d64a8..140a9a8 100644 --- a/web/src/views/calendar/CalendarContextMenu.tsx +++ b/web/src/views/calendar/CalendarContextMenu.tsx @@ -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; try { - await cal.createEvent({ ...rest, title: t("Copy of {title}", { title: ev.title ?? t("event") }), participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial, 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, 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); diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index 36ebecb..4cf9926 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -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" && 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") && 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" && } - {ctx && 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 && 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 && } - {popover && setPopover(null)} onEdit={() => { setEditor({ event: popover.inst.event, start: popover.inst.start, end: popover.inst.end, allDay: popover.inst.allDay }); setPopover(null); }} />} + {popover && 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 && setEditor(null)} />} ); diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index d25ffbe..6b070c5 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -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 = {}; 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 = {}; for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v; - await cal.createEvent(clean as Partial, calendarId, invites); + await cal.createEvent(clean as Partial, 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 )}
- setTarget(e.target.value)}> + {targets.map((x) => )}
setLocation(e.target.value)} placeholder={translate("Add location")} />
diff --git a/web/src/views/calendar/EventPopover.tsx b/web/src/views/calendar/EventPopover.tsx index 4269f22..8ab8905 100644 --- a/web/src/views/calendar/EventPopover.tsx +++ b/web/src/views/calendar/EventPopover.tsx @@ -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) {