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/styles/app.css b/web/src/styles/app.css index add8b79..b9c3578 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1030,3 +1030,8 @@ button.dp-open:disabled { cursor: default; opacity: .5; } .sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .spin { animation: spin 1s linear infinite; } @media (prefers-reduced-motion: reduce) { .spin { animation: none; } } + +/* The composer's To label doubles as the way into the address books. */ +.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; } +.composer-field label .link-btn:hover { color: var(--accent); } +.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; } 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/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index bb368a3..1e0b7d4 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AlertTriangle, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react"; +import { AlertTriangle, BookUser, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react"; import { useCompose, type Draft } from "@/store/compose"; import { useMail } from "@/store/mail"; import { useSettings } from "@/store/settings"; @@ -13,6 +13,7 @@ import { htmlToText, textToHtml } from "@/lib/text"; import { isValidEmail } from "@/lib/address"; import { attachmentIcon } from "../mail/MessageView"; import { FilePicker } from "./FilePicker"; +import { RecipientPicker, type Field } from "./RecipientPicker"; import { useFiles } from "@/store/files"; import { keyboard } from "@/lib/keyboard"; import { useIsMobile } from "@/ui/misc"; @@ -30,6 +31,7 @@ export function Composer({ draft }: { draft: Draft }) { const addFromFiles = useCompose((s) => s.addFromFiles); const filesAvailable = useFiles((s) => s.available); const [pickerOpen, setPickerOpen] = useState(false); + const [addressBookOpen, setAddressBookOpen] = useState(false); const removeAttachment = useCompose((s) => s.removeAttachment); const setIdentity = useCompose((s) => s.setIdentity); const insertTemplate = useCompose((s) => s.insertTemplate); @@ -174,9 +176,17 @@ export function Composer({ draft }: { draft: Draft }) { )}
- + patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} /> + {/* Beside Cc and Bcc, because that is where someone looks when + they are thinking about who the message goes to. The label + opens it too, for anyone who tries that first. */} + {!d.showCc && } {!d.showBcc && } {!d.showReplyTo && } @@ -244,6 +254,20 @@ export function Composer({ draft }: { draft: Draft }) { } label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} /> {canSchedule && { sendMenu.close(); setScheduleOpen(true); }} />} + {addressBookOpen && ( + { + // Added to whatever is already there, and the field is opened if + // it was hidden -- picking a Bcc should not put one somewhere + // the writer cannot see it. + const existing = field === "to" ? d.to : field === "cc" ? d.cc : d.bcc; + const merged = [...existing]; + for (const a of addresses) if (!merged.some((x) => x.email.toLowerCase() === a.email.toLowerCase())) merged.push(a); + patch({ [field]: merged, ...(field === "cc" ? { showCc: true } : field === "bcc" ? { showBcc: true } : {}) }); + }} + onClose={() => setAddressBookOpen(false)} + /> + )} {pickerOpen && void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />} {canSchedule && scheduleOpen && ( setScheduleOpen(false)} onPick={scheduleFor} /> diff --git a/web/src/views/compose/RecipientPicker.tsx b/web/src/views/compose/RecipientPicker.tsx new file mode 100644 index 0000000..f0ad31f --- /dev/null +++ b/web/src/views/compose/RecipientPicker.tsx @@ -0,0 +1,161 @@ +import { useMemo, useState } from "react"; +import { Book, BookOpen, Search, Users, X } from "lucide-react"; +import { Dialog } from "@/ui/dialog"; +import { useContacts } from "@/store/contacts"; +import { contactDisplayName, contactEmails } from "@/lib/contacts"; +import type { ContactCard, EmailAddress } from "@/jmap/types"; + +export type Field = "to" | "cc" | "bcc"; + +/** One selectable address: a card can carry several, so the address is the unit. */ +interface Row { + key: string; + name: string | null; + email: string; + book: string; +} + +/** + * Choose recipients by looking through the address books. + * + * Autocomplete answers "finish this name for me", which is only useful when the + * writer already knows who they want. This answers the other question -- who is + * there? -- so the books can be read rather than recalled, and several people + * picked in one pass rather than typed one at a time. + * + * Each address is its own row, not each person: someone with a work address and + * a personal one is a choice to make, and a picker that offered the card and + * quietly took the first address would make it for them. + * + * Shared books are in here on the same footing as the reader's own, which is + * the point of having added them -- with the account named, so it is never a + * mystery whose list a name came from. + */ +export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, addresses: EmailAddress[]) => void; onClose: () => void }) { + const contacts = useContacts(); + const [q, setQ] = useState(""); + const [bookKey, setBookKey] = useState("all"); + const [picked, setPicked] = useState>({}); + + const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed); + const ownBooks = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); + + const rows = useMemo(() => { + const out: Row[] = []; + const push = (card: ContactCard, book: string, keyPrefix: string) => { + for (const a of contactEmails(card)) { + if (!a.email) continue; + out.push({ key: `${keyPrefix}:${card.id}:${a.email}`, name: a.name ?? contactDisplayName(card), email: a.email, book }); + } + }; + if (bookKey === "all" || !bookKey.includes(":")) { + for (const c of Object.values(contacts.cards)) { + if (bookKey !== "all" && !c.addressBookIds?.[bookKey]) continue; + push(c, contacts.books[Object.keys(c.addressBookIds ?? {})[0] ?? ""]?.name ?? "Contacts", "own"); + } + } + if (bookKey === "all" || bookKey.includes(":")) { + for (const [key, card] of Object.entries(contacts.sharedCards)) { + const accountId = key.slice(0, key.length - card.id.length - 1); + const inBook = subscribed.find((b) => b.accountId === accountId && card.addressBookIds?.[b.book.id]); + if (!inBook) continue; + if (bookKey !== "all" && bookKey !== `${accountId}:${inBook.book.id}`) continue; + push(card, `${inBook.book.name} · ${inBook.accountName}`, accountId); + } + } + const needle = q.trim().toLowerCase(); + const filtered = needle + ? out.filter((r) => `${r.name ?? ""} ${r.email}`.toLowerCase().includes(needle)) + : out; + return filtered.sort((a, b) => (a.name ?? a.email).localeCompare(b.name ?? b.email)); + }, [contacts.cards, contacts.sharedCards, contacts.books, subscribed, bookKey, q]); + + const chosen = Object.values(picked); + const toggle = (r: Row) => + setPicked((p) => { + const next = { ...p }; + if (next[r.key]) delete next[r.key]; + else next[r.key] = r; + return next; + }); + + const send = (field: Field) => { + onPick(field, chosen.map((r) => ({ name: r.name, email: r.email }))); + onClose(); + }; + + return ( + + + + + + + } + > +
+ {/* Same shape as the contact list's own search box. */} + + +
+ + {chosen.length > 0 && ( +
+ {chosen.map((r) => ( + + ))} +
+ )} + +
+ {!rows.length ? ( +

{q ? "Nobody matches that." : "No contacts in this address book."}

+ ) : ( + rows.map((r) => ( + + )) + )} +
+ + {!ownBooks.length && !subscribed.length && ( +

No address books yet.

+ )} +
+ ); +} 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. */}