Files
ihasmail-inbuxa/web/src/views/contacts/ContactsSidebar.tsx
T
jcoffey-dev 350f4f4197 Put address books in the left pane, other people's included
Address book sharing was withdrawn a few hours ago on a report that it
behaved like mail folder sharing. That was wrong -- it works -- and it is
back, built the way Files is rather than the way it was.

Three things it inherits from Files. Shared books are listed in the app's
own left pane instead of behind an account switch in the profile menu.
The reader's books and other people's sit under separate headings, since
a book belonging to somebody else behaves differently and a single merged
list would be quiet about whose contacts you are reading. And opening
Contacts re-reads the session, so a book shared while the tab was open
turns up without signing out and in again.

The books pane the view kept to itself is gone, and with it the last
module that ignored the sidebar it was given.

The one thing Files does not need: shared contacts have to answer when
somebody types a name into a To field, so they are loaded up front rather
than when a book is opened, and they are offered by `suggest` and found
by `lookupByEmail` alongside the reader's own. Their own cards win a tie,
since a card someone wrote themselves should beat a colleague's copy of
the same person. That is the difference between a shared book you can
look at and one you can use.

Cards from a shared account are held apart from the reader's rather than
merged in, and keyed by account as well as id. Ids are only unique within
an account -- two accounts each having a book `ab1` is ordinary -- and a
flat map would have had one silently replace the other.

The mock grew an address book in its shared account, with contacts in it,
because none of this could be exercised otherwise.

KNOWN-ISSUES records the withdrawal as the mistake it was rather than
leaving it in the history looking like a finding. Mail folder sharing
stays withdrawn: that one really is broken.
2026-08-27 10:40:50 -07:00

179 lines
7.3 KiB
TypeScript

import { useEffect, useState } from "react";
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users } from "lucide-react";
import { useContacts } from "@/store/contacts";
import { useSession } from "@/store/session";
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";
/**
* 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<void> {
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 [menuBook, setMenuBook] = useState<AddressBook | null>(null);
const [share, setShare] = useState<AddressBook | null>(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;
return (
<>
<div className="nav-section"><span>Contacts</span></div>
<div className={`nav-item ${isOn(null, "all") ? "active" : ""}`} onClick={() => contacts.select({ accountId: null, bookId: "all" })}>
<Users size={17} />
<span className="grow truncate">All contacts</span>
</div>
<div className="nav-section">
<span>My address books</span>
<button
className="icon-btn sm"
title="New address book"
aria-label="New address book"
onClick={async () => {
const name = await promptDialog({ title: "New address book", placeholder: "Name" });
if (!name?.trim()) return;
try {
await contacts.createBook(name.trim());
} catch (err) {
toast.error((err as Error).message);
}
}}
>
<Plus size={14} />
</button>
</div>
{own.map((b) => (
<div
key={b.id}
className={`nav-item ${isOn(null, b.id) ? "active" : ""}`}
onClick={() => contacts.select({ accountId: null, bookId: b.id })}
onContextMenu={(e) => { e.preventDefault(); setMenuBook(b); menu.openAt(e.clientX, e.clientY); }}
>
<Book size={17} />
<span className="grow truncate">{b.name}</span>
{Object.keys(b.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label="Shared" />}
</div>
))}
<div className="nav-section">
<span>Shared with me</span>
<button
className="icon-btn sm"
title="Check for new shares"
aria-label="Check for new shares"
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
>
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
</button>
</div>
{contacts.sharedBooks.map(({ accountId, accountName, book }) => (
<div
key={`${accountId}:${book.id}`}
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
onClick={() => contacts.select({ accountId, bookId: book.id })}
title={`${book.name} — shared by ${accountName}`}
>
<BookOpen size={17} />
<span className="grow truncate">{book.name}</span>
</div>
))}
{!contacts.sharedBooks.length && (
<p className="hint" style={{ padding: "4px 12px" }}>
{contacts.sharedLoaded ? "Nothing is shared with you." : "Looking…"}
</p>
)}
{/* Import and export lived in the pane this replaced. */}
<div style={{ padding: "12px 8px" }} className="col gap-8">
<label className="btn btn-sm btn-block">
<Upload size={14} /> Import vCard
<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} />
</label>
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> Export {sel.bookId === "all" ? "all" : "book"}</button>
</div>
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
{menuBook && (
<>
<MenuItem
icon={<Pencil size={16} />}
label="Rename"
onClick={async () => {
const name = await promptDialog({ title: "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);
}
}}
/>
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuBook.myRights?.mayShare} onClick={() => setShare(menuBook)} />
<MenuSep />
<MenuItem
danger
icon={<Trash2 size={16} />}
label="Delete"
disabled={menuBook.isDefault}
onClick={async () => {
if (!(await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "The contacts in it go too.", confirmLabel: "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);
}
}}
/>
</>
)}
</Popover>
{share && <ShareDialog kind="AddressBook" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
</>
);
}