diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 7a9ad48..9ee411f 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -3,6 +3,7 @@ import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types"; import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates"; import { settings } from "./settings"; +import { toast } from "@/ui/toast"; import { useSession } from "./session"; export interface EventInstance { @@ -146,10 +147,15 @@ export const useCalendar = create((set, get) => ({ }, async setSharedSubscribed(accountId, calendarId, subscribed) { + // See the note in the contacts store: subscribing writes to another + // account, so a refusal is an ordinary answer and arrives in `notUpdated` + // rather than as a thrown error. try { - await client.call("Calendar/set", { accountId, update: { [calendarId]: { isSubscribed: subscribed } } }); + const res = await client.call("Calendar/set", { accountId, update: { [calendarId]: { isSubscribed: subscribed } } }); + const err = res.notUpdated?.[calendarId]; + if (err) throw new Error(setErrorMessage(err)); } catch (err) { - set({ error: (err as Error).message }); + toast.error(`Could not ${subscribed ? "add" : "remove"} that calendar: ${(err as Error).message}`); return; } set((s) => ({ diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index 225d711..546617b 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types"; import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts"; +import { toast } from "@/ui/toast"; import { useSession } from "./session"; import { useMail } from "./mail"; @@ -166,10 +167,20 @@ export const useContacts = create((set, get) => ({ }, async setBookSubscribed(accountId, bookId, subscribed) { + /* + * `notUpdated` matters more here than anywhere else this pattern is used. + * Subscribing is a write to somebody *else's* account, so it is the one + * call in the app that a perfectly healthy server is entitled to refuse -- + * and a refusal arrives as a successful response carrying a per-object + * failure, not as a thrown error. Ignoring it made a refused subscribe look + * exactly like a button that does nothing. + */ try { - await client.call("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } }); + const res = await client.call("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } }); + const err = res.notUpdated?.[bookId]; + if (err) throw new Error(setErrorMessage(err)); } catch (err) { - set({ error: (err as Error).message }); + toast.error(`Could not ${subscribed ? "add" : "remove"} that address book: ${(err as Error).message}`); return; } if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) {