import { useEffect, useRef, useState } from "react"; import { Book, BookOpen, Download, Eraser, MoreVertical, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react"; import { useContacts } from "@/store/contacts"; import { setErrorMessage } from "@/jmap/client"; import { useSession } from "@/store/session"; import { useSettings } from "@/store/settings"; import type { AddressBook } from "@/jmap/types"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { confirmDialog, promptDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { ShareDialog } from "../settings/ShareDialog"; import { plural, t } from "@/lib/i18n"; /** * Re-read the session so newly shared books appear without a sign-in. * * Shared accounts arrive in the JMAP session, which is otherwise fetched once * and refreshed only when a state change is pushed to this tab. Opening * Contacts is when the answer matters, so that is when it is asked for -- * throttled, since this is navigated to often and usually says nothing new. */ let lastRefresh = 0; async function refreshShares(force = false): Promise { const now = Date.now(); if (!force && now - lastRefresh < 30_000) return; lastRefresh = now; try { await useSession.getState().refresh(); } catch { return; } await useContacts.getState().init(); } /** * Address books in the app's own left pane, the reader's above and other * people's below. * * The two are kept plainly apart rather than merged into one list: a book that * belongs to somebody else behaves differently -- you cannot add to it, and * what you do see depends on what they granted -- and a list that hid that * distinction would be lying about whose contacts these are. */ export function ContactsSidebar() { /* Import and export are the view's to carry out -- it holds the cards -- so they are asked for by event rather than reaching across into it. What has changed is that the event now names the book, instead of meaning "whatever is selected". */ const onImport = (file: File, bookId: string) => window.dispatchEvent(new CustomEvent("ihm:contacts-import", { detail: { file, bookId } })); const onExport = (accountId: string | null, bookId: string) => window.dispatchEvent(new CustomEvent("ihm:contacts-export", { detail: { accountId, bookId } })); const contacts = useContacts(); const settings = useSettings((s) => s.settings); /* * What the open menu belongs to. One state rather than three, because the * rows differ in what they can offer: everything can be exported, only your * own can be imported into, renamed, shared or deleted. */ type MenuTarget = | { kind: "all" } | { kind: "own"; book: AddressBook } | { kind: "shared"; accountId: string; book: AddressBook }; const [target, setTarget] = useState(null); const menuBook = target && target.kind === "own" ? target.book : null; /* * The file picker for "Import contacts…". A MenuItem is a button and cannot * wrap a hidden input, so the input lives at the end of the sidebar and the * menu item reaches it through this -- the same arrangement the calendar's * iCAL import uses, which is the point of #224. * * The book is remembered separately because opening the picker closes the * menu, and `target` goes with it: by the time a file comes back there would * be nothing left saying which book it was chosen for. */ const fileRef = useRef(null); const importInto = useRef(null); const openMenu = (e: React.MouseEvent, t: MenuTarget) => { e.stopPropagation(); e.preventDefault(); setTarget(t); menu.open(e); }; const openMenuAt = (e: React.MouseEvent, t: MenuTarget) => { e.preventDefault(); setTarget(t); menu.openAt(e.clientX, e.clientY); }; const [share, setShare] = useState(null); const [refreshing, setRefreshing] = useState(false); const menu = useMenu(); useEffect(() => { void refreshShares(); }, []); if (!contacts.available) return null; 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; /* Added if the server says so or the reader's settings do -- Stalwart will not take the flag on a book shared read-only, so the settings carry it. */ const added = new Set(settings.addedShares); const isAdded = (accountId: string, bookId: string) => added.has(`${accountId}:${bookId}`); const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed || isAdded(b.accountId, b.book.id)); const available = contacts.sharedBooks.filter((b) => !(b.book.isSubscribed || isAdded(b.accountId, b.book.id))); return ( <>
{t("Contacts")}
contacts.select({ accountId: null, bookId: "all" })} onContextMenu={(e) => openMenuAt(e, { kind: "all" })} > {t("All contacts")}
{t("My address books")}
{own.map((b) => (
contacts.select({ accountId: null, bookId: b.id })} onContextMenu={(e) => openMenuAt(e, { kind: "own", book: b })} > {b.name} {Object.keys(b.shareWith ?? {}).length > 0 && }
))}
{t("Shared with me")}
{subscribed.map(({ accountId, accountName, book }) => (
contacts.select({ accountId, bookId: book.id })} title={`${book.name} — shared by ${accountName}`} onContextMenu={(e) => openMenuAt(e, { kind: "shared", accountId, book })} > {book.name} {/* A menu rather than the bare X it replaces: somebody else's book can still be exported, and losing that when the sidebar's export button went would have been a regression dressed as a tidy-up. */}
))} {!subscribed.length && (

{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 && ( <>
{t("Available to add")}
{available.map(({ accountId, accountName, book }) => (
{book.name}
))} )} { const f = e.target.files?.[0]; const into = importInto.current; if (f && into) onImport(f, into); e.target.value = ""; }} /> {target && ( <> {/* Exporting is the one thing every row can do -- your own books, somebody else's, and the whole lot together. */} } label={target.kind === "all" ? t("Export all contacts") : t("Export address book")} onClick={() => onExport(target.kind === "shared" ? target.accountId : null, target.kind === "all" ? "all" : target.book.id)} /> {/* Importing needs somewhere to put them. "All contacts" is not a book, so it files into the default one, which is what the button at the foot of the sidebar quietly did anyway. */} {target.kind !== "shared" && ( } label={t("Import contacts…")} onClick={() => { importInto.current = target.kind === "all" ? "all" : target.book.id; fileRef.current?.click(); }} /> )} {target.kind === "shared" && ( <> } label={t("Remove from my contacts")} onClick={() => void contacts.setBookSubscribed(target.accountId, target.book.id, false)} /> )} )} {menuBook && ( <> } label={t("Rename")} onClick={async () => { const name = await promptDialog({ title: t("Rename address book"), defaultValue: menuBook.name }); if (!name?.trim() || name === menuBook.name) return; try { await contacts.updateBook(menuBook.id, { name: name.trim() }); } catch (err) { toast.error((err as Error).message); } }} /> } label={t("Share…")} disabled={!menuBook.myRights?.mayShare} onClick={() => setShare(menuBook)} /> {/* Revoking the lot, rather than removing people one at a time in the dialog. Only shown when there is something to revoke. */} {Object.keys(menuBook.shareWith ?? {}).length > 0 && ( } label={t("Stop sharing")} disabled={!menuBook.myRights?.mayShare} onClick={async () => { const who = Object.keys(menuBook.shareWith ?? {}).length; if (!(await confirmDialog({ title: t("Stop sharing “{name}”?", { name: menuBook.name }), message: plural(who, { one: "{n} person will lose access. The contacts in it are not affected.", other: "{n} people will lose access. The contacts in it are not affected." }), confirmLabel: t("Stop sharing"), danger: true, }))) return; try { await contacts.updateBook(menuBook.id, { shareWith: null }); toast.success(t("No longer shared")); } catch (err) { toast.error((err as Error).message); } }} /> )} {/* The operation a migration actually asks for: import, notice something is wrong, empty the book, correct the export, import again. Offered on your own books only -- emptying somebody else's is a write to their account, which this client cannot make. Kept apart from Delete, which takes the book with it. A default book cannot be deleted and can perfectly well be emptied, which is most of why this is worth having as its own entry. */} } label={t("Empty address book")} onClick={async () => { const n = Object.values(contacts.cards).filter((c) => c.addressBookIds?.[menuBook.id]).length; if (!n) { toast.error(t("There is nothing in it to delete")); return; } if (!(await confirmDialog({ title: t("Empty “{name}”?", { name: menuBook.name }), message: plural(n, { one: "{n} contact will be deleted. This cannot be undone.", other: "{n} contacts will be deleted. This cannot be undone.", }), confirmLabel: t("Delete them"), danger: true, }))) return; try { const { destroyed, unfiled, refused } = await contacts.emptyBook(menuBook.id); if (destroyed) toast.success(plural(destroyed, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })); /* Said out loud, because it is the one part of emptying a book that is not a deletion and would otherwise look like contacts that refused to go. */ if (unfiled) { toast.show(plural(unfiled, { one: "{n} was also in another address book and was only removed from this one", other: "{n} were also in other address books and were only removed from this one", }), { duration: 9000 }); } if (refused) toast.error(t("Some could not be deleted: {error}", { error: setErrorMessage(refused) })); else if (!destroyed && !unfiled) toast.error(t("Nothing was deleted")); } catch (err) { toast.error((err as Error).message); } }} /> } label={t("Delete")} disabled={menuBook.isDefault} onClick={async () => { if (!(await confirmDialog({ title: t("Delete “{name}”?", { name: menuBook.name }), message: t("The contacts in it go too."), confirmLabel: t("Delete"), danger: true }))) return; try { await contacts.destroyBook(menuBook.id); if (sel.bookId === menuBook.id) contacts.select({ accountId: null, bookId: "all" }); } catch (err) { toast.error((err as Error).message); } }} /> )} {share && setShare(null)} />} ); }