import { useEffect, useState } from "react"; import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react"; import { useContacts } from "@/store/contacts"; 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 act on the list the view is showing, so they are asked for by event rather than reaching across into it. */ const onImport = (file: File) => window.dispatchEvent(new CustomEvent("ihm:contacts-import", { detail: file })); const onExport = () => window.dispatchEvent(new CustomEvent("ihm:contacts-export")); const contacts = useContacts(); const settings = useSettings((s) => s.settings); const [menuBook, setMenuBook] = useState(null); 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" })}> {t("All contacts")}
{t("My address books")}
{own.map((b) => (
contacts.select({ accountId: null, bookId: b.id })} onContextMenu={(e) => { e.preventDefault(); setMenuBook(b); menu.openAt(e.clientX, e.clientY); }} > {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}`} > {book.name}
))} {!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}
))} )} {/* Import and export lived in the pane this replaced. */}
{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); } }} /> )} } 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)} />} ); }