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.
This commit is contained in:
2026-08-27 11:32:49 -07:00
parent d9995cd0b4
commit 3416a41de9
2 changed files with 21 additions and 4 deletions
+8 -2
View File
@@ -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<CalendarState>((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<SetResponse>("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) => ({
+13 -2
View File
@@ -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<ContactsState>((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<SetResponse>("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) {