From 3416a41de91747fa548367195d6d8f4650d20463 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 27 Aug 2026 11:32:49 -0700 Subject: [PATCH] Say so when the server refuses a subscribe Adding a shared address book did nothing in one browser and worked in another. The button was not broken; the refusal was invisible. Subscribing is the one call in the app that writes to somebody else's account, so it is the one a perfectly healthy server is entitled to say no to -- and JMAP says no to a `/set` by answering successfully with the object listed in `notUpdated`. Neither subscribe method looked. The promise resolved, the code carried on, the re-read came back unchanged, and the row stayed exactly where it was with nothing said. Every other `/set` in this codebase reads `notUpdated` and raises. These two were written without it, which is the whole defect: not a wrong answer, an unread one. Both now check it and say what the server said, which is the thing that was missing -- whatever the underlying refusal turns out to be, it can be read off the screen instead of guessed at from which browser was in front of you. --- web/src/store/calendar.ts | 10 ++++++++-- web/src/store/contacts.ts | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) 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) {