From 4b0a3dca6c0c5f45d3a1b0320975624487790d73 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 9 Oct 2026 15:33:59 -0700 Subject: [PATCH] Calendar: write shared events in the account that holds them Edit, delete, a reply and a single date of a shared series were all sent to the reader's own account under the owner's event id (#49). Ids only mean something inside their account and Stalwart's are short, so that was notFound at best and at worst changed or deleted the reader's own, unrelated event of the same id. New events couldn't go into a shared calendar at all, even with write rights. - An instance from a shared calendar carries its account, and is keyed by it, so it no longer shares a key with one of the reader's own - getEvent, createEvent, updateEvent, destroyEvent and rsvp take that account (default: the reader's own); the occurrence lookup runs there too, and a shared delete clears the shared copy, not the reader's - The popover, drag-to-move, the context menu (duplicate included) and the editor pass it through - The editor offers shared calendars the reader may write to, named with whose they are; an existing event keeps to its own account, since JMAP has no moving an event between accounts Fixes #49 --- .../__tests__/shared-calendar-writes.test.ts | 175 ++++++++++++++++++ web/src/store/calendar.ts | 67 +++++-- .../views/calendar/CalendarContextMenu.tsx | 8 +- web/src/views/calendar/CalendarView.tsx | 6 +- web/src/views/calendar/EventEditor.tsx | 49 ++++- web/src/views/calendar/EventPopover.tsx | 4 +- 6 files changed, 274 insertions(+), 35 deletions(-) create mode 100644 web/src/store/__tests__/shared-calendar-writes.test.ts 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 af9df35..6c4277a 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; @@ -692,23 +715,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; @@ -717,7 +744,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 @@ -726,7 +753,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. @@ -739,7 +766,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 @@ -748,15 +775,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 }; @@ -764,7 +797,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 = {}; @@ -777,7 +810,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) { -- 2.54.0