diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 2ae5c55..7b96fe8 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -181,7 +181,7 @@ let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: const sieveScripts: Obj[] = []; /* A calendar in the shared account, so "Shared with me" and a colleague's events appearing in the grid can be exercised. Read-only, as a share is. */ -const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }]; +const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }]; const sharedEvents: Obj[] = []; const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events); const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars); @@ -207,7 +207,7 @@ const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, s /* A book in the shared account, so "Shared with me" and addressing a message from somebody else's contacts can be exercised at all. Read-only, which is what a share usually is. */ -const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights(false) }]; +const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }]; const sharedCards: Obj[] = [ { id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "katherine@example.org", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() }, { id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "dorothy@example.org", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() }, @@ -732,7 +732,7 @@ const handlers: Record = { "SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; }, "SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }), "Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a), - "Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })), + "Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a), "CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; }, "CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a), // Stalwart 0.16 rejects the RFC 8984 array outright and silently discards @@ -751,7 +751,7 @@ const handlers: Record = { "Principal/get": genericGet(principals), "Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }), "AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a), - "AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })), + "AddressBook/set": (a) => genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a), "ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; }, "ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a), "ContactCard/set": genericSet(cards, "cc"), diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 542dc10..7a9ad48 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -48,6 +48,8 @@ interface CalendarState { /** Calendars from accounts that shared with the reader, and their events. */ loadSharedCalendars(): Promise; loadSharedRange(start: Date, end: Date): Promise; + /** Add a shared calendar to, or remove it from, the reader's own view. */ + setSharedSubscribed(accountId: Id, calendarId: Id, subscribed: boolean): Promise; loadRange(start: Date, end: Date, force?: boolean): Promise; instancesIn(start: Date, end: Date): EventInstance[]; getEvent(id: Id): Promise; @@ -143,6 +145,26 @@ export const useCalendar = create((set, get) => ({ } }, + async setSharedSubscribed(accountId, calendarId, subscribed) { + try { + await client.call("Calendar/set", { accountId, update: { [calendarId]: { isSubscribed: subscribed } } }); + } catch (err) { + set({ error: (err as Error).message }); + return; + } + set((s) => ({ + sharedCalendars: s.sharedCalendars.map((c) => + c.accountId === accountId && c.calendar.id === calendarId ? { ...c, calendar: { ...c.calendar, isSubscribed: subscribed } } : c, + ), + })); + // Its events are only fetched for calendars in view, so the windows on + // screen have to be asked again either way. + for (const key of Object.keys(get().ranges)) { + const [from, to] = key.split("|").map((n) => new Date(Number(n))); + if (from && to) void get().loadSharedRange(from, to); + } + }, + /** The same window, from every account that shared a calendar. */ async loadSharedRange(start, end) { const shared = get().sharedCalendars; @@ -248,8 +270,14 @@ export const useCalendar = create((set, get) => ({ const accountId = k.slice(0, k.length - e.id.length - 1); const calId = Object.keys(e.calendarIds ?? {})[0]; if (calId && hidden[sharedKey(accountId, calId)]) continue; + /* Stalwart hands back every calendar in an account the reader can reach, + with full rights on each, whether or not anybody meant to share it -- + 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 theirs: Record = {}; - for (const c of sharedCalendars) if (c.accountId === accountId) theirs[c.calendar.id] = c.calendar; + for (const c of sharedCalendars) if (c.accountId === accountId && c.calendar.isSubscribed) theirs[c.calendar.id] = c.calendar; + if (calId && !theirs[calId]) continue; const inst = toInstance(e, theirs); if (!inst) continue; if (inst.end > start && inst.start < end) out.push(inst); diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index 5fc4d2e..225d711 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -53,6 +53,8 @@ interface ContactsState { /** Books and cards from accounts that shared with the reader. */ loadShared(): Promise; select(selection: BookSelection): void; + /** Add a shared address book to, or remove it from, the reader's own view. */ + setBookSubscribed(accountId: Id, bookId: Id, subscribed: boolean): Promise; /** The account a card belongs to, null for the reader's own. */ accountOfCard(id: Id): Id | null; getCard(id: Id): Promise; @@ -131,6 +133,18 @@ export const useContacts = create((set, get) => ({ try { const res = await client.call>("AddressBook/get", { accountId, ids: null }); for (const book of res.list) books.push({ accountId, accountName: account.name, book }); + /* + * Cards come only from books the reader has added. + * + * Stalwart hands back every book in a reachable account with full + * rights on each, shared or not -- an account linked for its files + * offered its address book too -- so `isSubscribed` is the only thing + * separating "shared with me" from "reachable". Loading the rest would + * 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)); + 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. const cardsRes = await client.chain([ @@ -138,7 +152,10 @@ export const useContacts = create((set, get) => ({ ["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"], ]); const g = cardsRes.get("g")?.[0] as unknown as GetResponse; - for (const c of g.list) cards[sharedKey(accountId, c.id)] = c; + for (const c of g.list) { + if (!Object.keys(c.addressBookIds ?? {}).some((id) => wanted.has(id))) continue; + cards[sharedKey(accountId, c.id)] = c; + } } catch { // An account that refuses is one that shared nothing here. Not an // error to show: the reader did not ask for it and cannot act on it. @@ -148,6 +165,19 @@ export const useContacts = create((set, get) => ({ set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true }); }, + async setBookSubscribed(accountId, bookId, subscribed) { + try { + await client.call("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } }); + } catch (err) { + set({ error: (err as Error).message }); + return; + } + if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) { + set({ selection: { accountId: null, bookId: "all" } }); + } + await get().loadShared(); + }, + select(selection) { set({ selection }); }, diff --git a/web/src/views/calendar/CalendarSidebar.tsx b/web/src/views/calendar/CalendarSidebar.tsx index 38756a7..f73aae3 100644 --- a/web/src/views/calendar/CalendarSidebar.tsx +++ b/web/src/views/calendar/CalendarSidebar.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useLocation } from "wouter"; -import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Users } from "lucide-react"; +import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, X } from "lucide-react"; import { useCalendar } from "@/store/calendar"; import { dateTimeKey, useSettings } from "@/store/settings"; import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates"; @@ -25,6 +25,8 @@ export function CalendarSidebar() { const [anchor, setAnchor] = useState(() => startOfDay(selected)); const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]); const menu = useMenu(); + const sharedSubscribed = cal.sharedCalendars.filter((c) => c.calendar.isSubscribed); + const sharedAvailable = cal.sharedCalendars.filter((c) => !c.calendar.isSubscribed); const [menuCal, setMenuCal] = useState(null); const [editCal, setEditCal] = useState | null>(null); const [share, setShare] = useState(null); @@ -63,26 +65,52 @@ export function CalendarSidebar() { ))} - {/* Calendars other people shared. Separate from the reader's own, the way - Files and Contacts separate theirs: you cannot edit these, and which - of them you can see at all is somebody else's decision. Hiding one is - remembered under an account-qualified key, since a calendar id means - nothing outside the account holding it. */} - {cal.sharedCalendars.length > 0 && ( + {/* Calendars other people shared, split by whether the reader has added + them. Stalwart returns every calendar in a reachable account with full + rights, so "shared with me" and "there is an account here at all" look + identical -- `isSubscribed` is the only thing that tells them apart, + and adding one is a deliberate act rather than a guess on our part. */} + {sharedSubscribed.length > 0 && ( <>
Shared with me
- {cal.sharedCalendars.map(({ accountId, accountName, calendar: c }) => { + {sharedSubscribed.map(({ accountId, accountName, calendar: c }) => { const key = `${accountId}:${c.id}`; return (
cal.toggleHidden(key)} title={`${c.name} — shared by ${accountName}`}> {c.name} - +
); })} )} + {sharedAvailable.length > 0 && ( + <> +
Available to add
+ {sharedAvailable.map(({ accountId, accountName, calendar: c }) => ( +
+ + {c.name} + +
+ ))} + + )} {menuCal && ( diff --git a/web/src/views/contacts/ContactsSidebar.tsx b/web/src/views/contacts/ContactsSidebar.tsx index 16bc9fd..76a858c 100644 --- a/web/src/views/contacts/ContactsSidebar.tsx +++ b/web/src/views/contacts/ContactsSidebar.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users } from "lucide-react"; +import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users, X } from "lucide-react"; import { useContacts } from "@/store/contacts"; import { useSession } from "@/store/session"; import type { AddressBook } from "@/jmap/types"; @@ -58,6 +58,8 @@ export function ContactsSidebar() { const own = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); const sel = contacts.selection; const isOn = (accountId: string | null, bookId: string) => sel.accountId === accountId && sel.bookId === bookId; + const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed); + const available = contacts.sharedBooks.filter((b) => !b.book.isSubscribed); return ( <> @@ -110,7 +112,7 @@ export function ContactsSidebar() { - {contacts.sharedBooks.map(({ accountId, accountName, book }) => ( + {subscribed.map(({ accountId, accountName, book }) => (
{book.name} +
))} - {!contacts.sharedBooks.length && ( + {!subscribed.length && (

- {contacts.sharedLoaded ? "Nothing is shared with you." : "Looking…"} + {contacts.sharedLoaded ? "Nothing added yet." : "Looking…"}

)} + {/* Stalwart returns every book in a reachable account with full rights, + shared or not, so adding one is the reader's decision rather than a + guess made on their behalf. */} + {available.length > 0 && ( + <> +
Available to add
+ {available.map(({ accountId, accountName, book }) => ( +
+ + {book.name} + +
+ ))} + + )} + {/* Import and export lived in the pane this replaced. */}