Remember an added address book when the server will not
"You are not allowed to modify this address book." That is Stalwart's answer to a sharee subscribing to a book shared read-only, and it is a fair one: `isSubscribed` lives on the collection rather than on the reader, so adding one is a write to the *owner's* account. The identical write on a shared calendar is accepted. The difference is the server's. So the flag is still asked for first -- a preference the server holds is one every client agrees about -- and when it is refused the answer goes in the reader's own synced settings instead, as `addedShares`, keyed by account and collection. Either record counts as added, and the rule has a test of its own because three components ask the question and they must not drift apart. Two things about how this hid. The refusal arrives as a *successful* response with the id in `notUpdated`, so the version that ignored it saw nothing wrong and the button simply did nothing -- fixed a commit ago, and it is what turned "the + does nothing in Firefox" into a sentence from the server. And it cannot be seen from the owner's account at all, where the write succeeds: it took two browsers signed in as two accounts to find, which is why it survived every check made from one. The mock refuses the same write for the same reason. One that accepted it would have gone on agreeing with the belief that shipped. Verified against it: adding the shared book is refused by the server, recorded in settings, and the book moves to "Shared with me" with its contacts reaching the To field; removing undoes all three; and it survives a full page reload, which is the point of putting it where the settings live rather than in this tab.
This commit is contained in:
@@ -2,8 +2,7 @@ import { create } from "zustand";
|
||||
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 { settings, useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
|
||||
export interface EventInstance {
|
||||
@@ -150,13 +149,26 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
// 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.
|
||||
/*
|
||||
* Server first, settings when it refuses -- the same arrangement the
|
||||
* contacts store explains. Stalwart takes this write on a shared calendar
|
||||
* where it will not on a shared address book, but the difference is the
|
||||
* server's to change and not worth relying on from here.
|
||||
*/
|
||||
let stored = false;
|
||||
try {
|
||||
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) {
|
||||
toast.error(`Could not ${subscribed ? "add" : "remove"} that calendar: ${(err as Error).message}`);
|
||||
return;
|
||||
stored = true;
|
||||
} catch {
|
||||
stored = false;
|
||||
}
|
||||
if (!stored) {
|
||||
const added = new Set(settings().addedShares);
|
||||
if (subscribed) added.add(sharedKey(accountId, calendarId));
|
||||
else added.delete(sharedKey(accountId, calendarId));
|
||||
useSettings.getState().update({ addedShares: [...added] });
|
||||
}
|
||||
set((s) => ({
|
||||
sharedCalendars: s.sharedCalendars.map((c) =>
|
||||
@@ -281,8 +293,13 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
an account linked for its files offered its calendar too. `isSubscribed`
|
||||
is the only thing separating "shared with me" from "reachable", so
|
||||
nothing unsubscribed is drawn. */
|
||||
const added = new Set(settings().addedShares);
|
||||
const theirs: Record<Id, Calendar> = {};
|
||||
for (const c of sharedCalendars) if (c.accountId === accountId && c.calendar.isSubscribed) theirs[c.calendar.id] = c.calendar;
|
||||
for (const c of sharedCalendars) {
|
||||
if (c.accountId !== accountId) continue;
|
||||
if (!c.calendar.isSubscribed && !added.has(sharedKey(c.accountId, c.calendar.id))) continue;
|
||||
theirs[c.calendar.id] = c.calendar;
|
||||
}
|
||||
if (calId && !theirs[calId]) continue;
|
||||
const inst = toInstance(e, theirs);
|
||||
if (!inst) continue;
|
||||
|
||||
@@ -2,7 +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 { useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
import { useMail } from "./mail";
|
||||
|
||||
@@ -144,7 +144,8 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
* put a stranger's contacts in the To field, which is the one place
|
||||
* this must not guess.
|
||||
*/
|
||||
const wanted = new Set(res.list.filter((b) => b.isSubscribed).map((b) => b.id));
|
||||
const added = new Set(useSettings.getState().settings.addedShares);
|
||||
const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id));
|
||||
if (!wanted.size) continue;
|
||||
// One page. A shared book is a colleague's contacts, not an archive,
|
||||
// and the alternative is holding the reader's own list hostage to it.
|
||||
@@ -175,13 +176,32 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
* failure, not as a thrown error. Ignoring it made a refused subscribe look
|
||||
* exactly like a button that does nothing.
|
||||
*/
|
||||
/*
|
||||
* Ask the server to remember it, and remember it here when it will not.
|
||||
*
|
||||
* Subscribing writes to the owner's account, and Stalwart 0.16.19 refuses
|
||||
* that for a book shared read-only -- "You are not allowed to modify this
|
||||
* address book" -- while accepting the same write on a shared calendar. The
|
||||
* server's own flag is still preferred when it takes it, because then every
|
||||
* client agrees; a refusal is an ordinary answer here rather than a
|
||||
* failure, and the preference goes in the reader's own synced settings.
|
||||
*/
|
||||
const key = sharedKey(accountId, bookId);
|
||||
let stored = false;
|
||||
try {
|
||||
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) {
|
||||
toast.error(`Could not ${subscribed ? "add" : "remove"} that address book: ${(err as Error).message}`);
|
||||
return;
|
||||
stored = true;
|
||||
} catch {
|
||||
stored = false;
|
||||
}
|
||||
if (!stored) {
|
||||
const { settings, update } = useSettings.getState();
|
||||
const added = new Set(settings.addedShares);
|
||||
if (subscribed) added.add(key);
|
||||
else added.delete(key);
|
||||
update({ addedShares: [...added] });
|
||||
}
|
||||
if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) {
|
||||
set({ selection: { accountId: null, bookId: "all" } });
|
||||
|
||||
@@ -33,6 +33,21 @@ export interface Settings {
|
||||
showAvatars: boolean;
|
||||
pageSize: number;
|
||||
markReadDelay: number; // seconds; -1 = never auto
|
||||
/**
|
||||
* Shared calendars and address books the reader has added, as
|
||||
* `accountId:collectionId`.
|
||||
*
|
||||
* JMAP keeps this on the collection itself, in `isSubscribed`, and that is
|
||||
* still tried first -- a preference the server holds is one every client
|
||||
* sees. But subscribing writes to the *owner's* account, and Stalwart 0.16.19
|
||||
* refuses that for an address book shared read-only: "You are not allowed to
|
||||
* modify this address book." It accepts the same write on a shared calendar,
|
||||
* which is the inconsistency this list exists to paper over.
|
||||
*
|
||||
* So where the server will not remember, ihasmail does, in the settings that
|
||||
* already follow the reader between devices.
|
||||
*/
|
||||
addedShares: string[];
|
||||
imagePolicy: ImagePolicy;
|
||||
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
|
||||
themeMessageBody: boolean;
|
||||
@@ -128,6 +143,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
showAvatars: true,
|
||||
pageSize: 50,
|
||||
markReadDelay: 0,
|
||||
addedShares: [],
|
||||
imagePolicy: "ask",
|
||||
themeMessageBody: false,
|
||||
undoSendSeconds: 8,
|
||||
|
||||
Reference in New Issue
Block a user