Give the address books the menus the calendars have

Two remarks from the reporter's colleague, both the same underlying thing:
contacts and calendar grew their menus at different times and it shows.

The dots button on hover. The calendar has offered its per-item menu two ways
since it was written -- the button and right-click -- and contacts only had
right-click, which is undiscoverable and unavailable on touch. The rows are
already .nav-item, which has carried the hover-reveal rule for mail folders
all along, so this is the button and no CSS.

Import and export move into those menus. As a pair of buttons at the foot of
the sidebar they did not say which address book they acted on -- they meant
"whatever is selected", which is not something a button can tell you. The
calendar settled this already: its iCAL import lives in the calendar's own
menu, because that is where "which one?" is answered by where you clicked.
The events they dispatch now name the book instead of meaning the selection.

Exporting a book now exports that book, rather than the list on screen. The
old one handed you whatever was showing, so a search box with something in it
quietly narrowed the export -- fine while the button sat under that list,
wrong from a menu in the sidebar.

Two things that would otherwise have been lost with the buttons. "All
contacts" gets the same menu, so exporting everything still has a home; and
a book somebody shared gets a menu rather than the bare X, since it can be
exported too and losing that would have been a regression dressed as a
tidy-up. The X moves inside as "Remove from my contacts".

Closes #224.
This commit is contained in:
2026-09-02 09:06:00 -07:00
parent a61fe28523
commit 8badf48c4a
11 changed files with 171 additions and 51 deletions
+5 -2
View File
@@ -363,8 +363,11 @@ export const catalog: Catalog = {
"No address books yet.": "Noch keine Adressbücher.", "No address books yet.": "Noch keine Adressbücher.",
"Choose from address books": "Aus Adressbüchern wählen", "Choose from address books": "Aus Adressbüchern wählen",
"Import vCard": "vCard importieren", "Import vCard": "vCard importieren",
"Export all": "Alle exportieren", "Export all contacts": "Alle Kontakte exportieren",
"Export book": "Adressbuch exportieren", "Export address book": "Dieses Adressbuch exportieren",
"Import contacts…": "Kontakte importieren…",
"Contact options": "Kontaktoptionen",
"Address book options": "Adressbuchoptionen",
"Email group": "Gruppe anschreiben", "Email group": "Gruppe anschreiben",
"Email everyone": "Alle anschreiben", "Email everyone": "Alle anschreiben",
"Members": "Mitglieder", "Members": "Mitglieder",
+5 -2
View File
@@ -355,8 +355,11 @@ export const catalog: Catalog = {
"No address books yet.": "Aún no hay libretas de direcciones.", "No address books yet.": "Aún no hay libretas de direcciones.",
"Choose from address books": "Elegir de las libretas de direcciones", "Choose from address books": "Elegir de las libretas de direcciones",
"Import vCard": "Importar una vCard", "Import vCard": "Importar una vCard",
"Export all": "Exportar todo", "Export all contacts": "Exportar todos los contactos",
"Export book": "Exportar la libreta", "Export address book": "Exportar esta libreta de direcciones",
"Import contacts…": "Importar contactos…",
"Contact options": "Opciones de contactos",
"Address book options": "Opciones de la libreta",
"Email group": "Escribir al grupo", "Email group": "Escribir al grupo",
"Email everyone": "Escribir a todos", "Email everyone": "Escribir a todos",
"Members": "Miembros", "Members": "Miembros",
+5 -2
View File
@@ -360,8 +360,11 @@ export const catalog: Catalog = {
"No address books yet.": "Aucun carnet d'adresses pour le moment.", "No address books yet.": "Aucun carnet d'adresses pour le moment.",
"Choose from address books": "Choisir dans les carnets d'adresses", "Choose from address books": "Choisir dans les carnets d'adresses",
"Import vCard": "Importer une vCard", "Import vCard": "Importer une vCard",
"Export all": "Tout exporter", "Export all contacts": "Exporter tous les contacts",
"Export book": "Exporter le carnet", "Export address book": "Exporter ce carnet dadresses",
"Import contacts…": "Importer des contacts…",
"Contact options": "Options des contacts",
"Address book options": "Options du carnet dadresses",
"Email group": "Écrire au groupe", "Email group": "Écrire au groupe",
"Email everyone": "Écrire à tous", "Email everyone": "Écrire à tous",
"Members": "Membres", "Members": "Membres",
+5 -2
View File
@@ -354,8 +354,11 @@ export const catalog: Catalog = {
"No address books yet.": "アドレス帳がまだありません。", "No address books yet.": "アドレス帳がまだありません。",
"Choose from address books": "アドレス帳から選択", "Choose from address books": "アドレス帳から選択",
"Import vCard": "vCard をインポート", "Import vCard": "vCard をインポート",
"Export all": "すべてエクスポート", "Export all contacts": "すべての連絡先をエクスポート",
"Export book": "アドレス帳をエクスポート", "Export address book": "このアドレス帳をエクスポート",
"Import contacts…": "連絡先をインポート…",
"Contact options": "連絡先のオプション",
"Address book options": "アドレス帳のオプション",
"Email group": "グループにメール", "Email group": "グループにメール",
"Email everyone": "全員にメール", "Email everyone": "全員にメール",
"Members": "メンバー", "Members": "メンバー",
+5 -2
View File
@@ -351,8 +351,11 @@ export const catalog: Catalog = {
"No address books yet.": "Nog geen adresboeken.", "No address books yet.": "Nog geen adresboeken.",
"Choose from address books": "Kiezen uit adresboeken", "Choose from address books": "Kiezen uit adresboeken",
"Import vCard": "vCard importeren", "Import vCard": "vCard importeren",
"Export all": "Alles exporteren", "Export all contacts": "Alle contacten exporteren",
"Export book": "Adresboek exporteren", "Export address book": "Dit adresboek exporteren",
"Import contacts…": "Contacten importeren…",
"Contact options": "Contactopties",
"Address book options": "Adresboekopties",
"Email group": "Groep e-mailen", "Email group": "Groep e-mailen",
"Email everyone": "Iedereen e-mailen", "Email everyone": "Iedereen e-mailen",
"Members": "Leden", "Members": "Leden",
+5 -2
View File
@@ -358,8 +358,11 @@ export const catalog: Catalog = {
"No address books yet.": "Ainda não há catálogos de endereços.", "No address books yet.": "Ainda não há catálogos de endereços.",
"Choose from address books": "Escolher nos catálogos de endereços", "Choose from address books": "Escolher nos catálogos de endereços",
"Import vCard": "Importar um vCard", "Import vCard": "Importar um vCard",
"Export all": "Exportar tudo", "Export all contacts": "Exportar todos os contatos",
"Export book": "Exportar o catálogo", "Export address book": "Exportar este catálogo de endereços",
"Import contacts…": "Importar contatos…",
"Contact options": "Opções de contatos",
"Address book options": "Opções do catálogo",
"Email group": "Escrever para o grupo", "Email group": "Escrever para o grupo",
"Email everyone": "Escrever para todos", "Email everyone": "Escrever para todos",
"Members": "Membros", "Members": "Membros",
+5 -2
View File
@@ -357,8 +357,11 @@ export const catalog: Catalog = {
"No address books yet.": "Адресных книг пока нет.", "No address books yet.": "Адресных книг пока нет.",
"Choose from address books": "Выбрать из адресных книг", "Choose from address books": "Выбрать из адресных книг",
"Import vCard": "Импорт vCard", "Import vCard": "Импорт vCard",
"Export all": "Экспортировать всё", "Export all contacts": "Экспортировать все контакты",
"Export book": "Экспортировать книгу", "Export address book": "Экспортировать эту адресную книгу",
"Import contacts…": "Импортировать контакты…",
"Contact options": "Параметры контактов",
"Address book options": "Параметры адресной книги",
"Email group": "Написать группе", "Email group": "Написать группе",
"Email everyone": "Написать всем", "Email everyone": "Написать всем",
"Members": "Участники", "Members": "Участники",
+5 -2
View File
@@ -351,8 +351,11 @@ export const catalog: Catalog = {
"No address books yet.": "Адресних книг поки немає.", "No address books yet.": "Адресних книг поки немає.",
"Choose from address books": "Вибрати з адресних книг", "Choose from address books": "Вибрати з адресних книг",
"Import vCard": "Імпорт vCard", "Import vCard": "Імпорт vCard",
"Export all": "Експортувати все", "Export all contacts": "Експортувати всі контакти",
"Export book": "Експортувати книгу", "Export address book": "Експортувати цю адресну книгу",
"Import contacts…": "Імпортувати контакти…",
"Contact options": "Параметри контактів",
"Address book options": "Параметри адресної книги",
"Email group": "Написати групі", "Email group": "Написати групі",
"Email everyone": "Написати всім", "Email everyone": "Написати всім",
"Members": "Учасники", "Members": "Учасники",
+5 -2
View File
@@ -353,8 +353,11 @@ export const catalog: Catalog = {
"No address books yet.": "还没有通讯录。", "No address books yet.": "还没有通讯录。",
"Choose from address books": "从通讯录中选择", "Choose from address books": "从通讯录中选择",
"Import vCard": "导入 vCard", "Import vCard": "导入 vCard",
"Export all": "全部导出", "Export all contacts": "导出所有联系人",
"Export book": "导出通讯录", "Export address book": "导出通讯录",
"Import contacts…": "导入联系人…",
"Contact options": "联系人选项",
"Address book options": "通讯录选项",
"Email group": "给该群组写邮件", "Email group": "给该群组写邮件",
"Email everyone": "给所有人写邮件", "Email everyone": "给所有人写邮件",
"Members": "成员", "Members": "成员",
+86 -26
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react"; import { Book, BookOpen, Download, MoreVertical, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
@@ -41,13 +41,39 @@ async function refreshShares(force = false): Promise<void> {
* distinction would be lying about whose contacts these are. * distinction would be lying about whose contacts these are.
*/ */
export function ContactsSidebar() { export function ContactsSidebar() {
/* Import and export act on the list the view is showing, so they are asked /* Import and export are the view's to carry out -- it holds the cards -- so
for by event rather than reaching across into it. */ they are asked for by event rather than reaching across into it. What has
const onImport = (file: File) => window.dispatchEvent(new CustomEvent("ihm:contacts-import", { detail: file })); changed is that the event now names the book, instead of meaning "whatever
const onExport = () => window.dispatchEvent(new CustomEvent("ihm:contacts-export")); 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 contacts = useContacts();
const settings = useSettings((s) => s.settings); const settings = useSettings((s) => s.settings);
const [menuBook, setMenuBook] = useState<AddressBook | null>(null); /*
* 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<MenuTarget | null>(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<HTMLInputElement>(null);
const importInto = useRef<string | null>(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<AddressBook | null>(null); const [share, setShare] = useState<AddressBook | null>(null);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const menu = useMenu(); const menu = useMenu();
@@ -71,9 +97,14 @@ export function ContactsSidebar() {
return ( return (
<> <>
<div className="nav-section"><span>{t("Contacts")}</span></div> <div className="nav-section"><span>{t("Contacts")}</span></div>
<div className={`nav-item ${isOn(null, "all") ? "active" : ""}`} onClick={() => contacts.select({ accountId: null, bookId: "all" })}> <div
className={`nav-item ${isOn(null, "all") ? "active" : ""}`}
onClick={() => contacts.select({ accountId: null, bookId: "all" })}
onContextMenu={(e) => openMenuAt(e, { kind: "all" })}
>
<Users size={17} /> <Users size={17} />
<span className="grow truncate">{t("All contacts")}</span> <span className="grow truncate">{t("All contacts")}</span>
<button className="icon-btn xs nav-more" onClick={(e) => openMenu(e, { kind: "all" })} aria-label={t("Contact options")}><MoreVertical size={14} /></button>
</div> </div>
<div className="nav-section"> <div className="nav-section">
@@ -100,11 +131,12 @@ export function ContactsSidebar() {
key={b.id} key={b.id}
className={`nav-item ${isOn(null, b.id) ? "active" : ""}`} className={`nav-item ${isOn(null, b.id) ? "active" : ""}`}
onClick={() => contacts.select({ accountId: null, bookId: b.id })} onClick={() => contacts.select({ accountId: null, bookId: b.id })}
onContextMenu={(e) => { e.preventDefault(); setMenuBook(b); menu.openAt(e.clientX, e.clientY); }} onContextMenu={(e) => openMenuAt(e, { kind: "own", book: b })}
> >
<Book size={17} /> <Book size={17} />
<span className="grow truncate">{b.name}</span> <span className="grow truncate">{b.name}</span>
{Object.keys(b.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label={t("Shared")} />} {Object.keys(b.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label={t("Shared")} />}
<button className="icon-btn xs nav-more" onClick={(e) => openMenu(e, { kind: "own", book: b })} aria-label={t("Address book options")}><MoreVertical size={14} /></button>
</div> </div>
))} ))}
@@ -125,17 +157,14 @@ export function ContactsSidebar() {
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`} className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
onClick={() => contacts.select({ accountId, bookId: book.id })} onClick={() => contacts.select({ accountId, bookId: book.id })}
title={`${book.name} — shared by ${accountName}`} title={`${book.name} — shared by ${accountName}`}
onContextMenu={(e) => openMenuAt(e, { kind: "shared", accountId, book })}
> >
<BookOpen size={17} /> <BookOpen size={17} />
<span className="grow truncate">{book.name}</span> <span className="grow truncate">{book.name}</span>
<button {/* A menu rather than the bare X it replaces: somebody else's book can
className="icon-btn sm" still be exported, and losing that when the sidebar's export button
title={t("Remove from my contacts")} went would have been a regression dressed as a tidy-up. */}
aria-label={t("Remove from my contacts")} <button className="icon-btn xs nav-more" onClick={(e) => openMenu(e, { kind: "shared", accountId, book })} aria-label={t("Address book options")}><MoreVertical size={14} /></button>
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, false); }}
>
<X size={13} />
</button>
</div> </div>
))} ))}
{!subscribed.length && ( {!subscribed.length && (
@@ -167,18 +196,49 @@ export function ContactsSidebar() {
</> </>
)} )}
{/* Import and export lived in the pane this replaced. */} <input
<div style={{ padding: "12px 8px" }} className="col gap-8"> ref={fileRef}
<label className="btn btn-sm btn-block"> type="file"
<Upload size={14} /> {t("Import contacts")} accept=".vcf,.vcard,.ldif,.ldi,text/vcard,text/directory"
<input type="file" accept=".vcf,.vcard,.ldif,.ldi,text/vcard,text/directory" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} /> hidden
</label> onChange={(e) => { const f = e.target.files?.[0]; const into = importInto.current; if (f && into) onImport(f, into); e.target.value = ""; }}
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> {sel.bookId === "all" ? t("Export all") : t("Export book")}</button> />
</div>
<Popover anchor={menu.anchor} onClose={menu.close} width={210}> <Popover anchor={menu.anchor} onClose={menu.close} width={230}>
{target && (
<>
{/* Exporting is the one thing every row can do -- your own books,
somebody else's, and the whole lot together. */}
<MenuItem
icon={<Download size={16} />}
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" && (
<MenuItem
icon={<Upload size={16} />}
label={t("Import contacts…")}
onClick={() => { importInto.current = target.kind === "all" ? "all" : target.book.id; fileRef.current?.click(); }}
/>
)}
{target.kind === "shared" && (
<>
<MenuSep />
<MenuItem
icon={<X size={16} />}
label={t("Remove from my contacts")}
onClick={() => void contacts.setBookSubscribed(target.accountId, target.book.id, false)}
/>
</>
)}
</>
)}
{menuBook && ( {menuBook && (
<> <>
<MenuSep />
<MenuItem <MenuItem
icon={<Pencil size={16} />} icon={<Pencil size={16} />}
label={t("Rename")} label={t("Rename")}
+40 -7
View File
@@ -32,8 +32,20 @@ export function ContactsView({ id }: { id?: string }) {
useEffect(() => { useEffect(() => {
const onNew = () => setEditing({}); const onNew = () => setEditing({});
const onImport = (ev: Event) => { const f = (ev as CustomEvent<File>).detail; if (f) void importFile(f); }; /*
const onExport = () => exportAll(); * Both carry the book they were asked for. They used to mean "whatever the
* list is showing", which was the whole of the complaint on #174: two
* buttons at the foot of the sidebar that did not say which address book
* they acted on. Now they are opened from a book's own menu and say so.
*/
const onImport = (ev: Event) => {
const d = (ev as CustomEvent<{ file: File; bookId: string }>).detail;
if (d?.file) void importFile(d.file, d.bookId);
};
const onExport = (ev: Event) => {
const d = (ev as CustomEvent<{ accountId: string | null; bookId: string }>).detail;
exportBook(d?.accountId ?? null, d?.bookId ?? "all");
};
window.addEventListener("ihm:new-contact", onNew); window.addEventListener("ihm:new-contact", onNew);
window.addEventListener("ihm:contacts-import", onImport); window.addEventListener("ihm:contacts-import", onImport);
window.addEventListener("ihm:contacts-export", onExport); window.addEventListener("ihm:contacts-export", onExport);
@@ -81,16 +93,37 @@ export function ContactsView({ id }: { id?: string }) {
return <div className="p-16"><Empty icon={<Users size={40} />} title={translate("Contacts are not available")}>{translate("This account does not have the JMAP contacts capability.")}</Empty></div>; return <div className="p-16"><Empty icon={<Users size={40} />} title={translate("Contacts are not available")}>{translate("This account does not have the JMAP contacts capability.")}</Empty></div>;
} }
const exportAll = () => { /*
const text = list.map(toVCard).join(""); * The cards of the book that was asked for, rather than the cards on screen.
* Exporting used to hand you the current list, which meant a search box with
* something in it quietly narrowed the export -- fine while the button sat
* under that list, wrong now that it is opened from a book in the sidebar.
*/
const cardsOf = (accountId: string | null, book: string) => {
if (accountId) {
const prefix = `${accountId}:`;
return Object.entries(contacts.sharedCards).filter(([key]) => key.startsWith(prefix)).map(([, c]) => c)
.filter((c) => book === "all" || c.addressBookIds?.[book]);
}
const mine = Object.values(contacts.cards);
return book === "all" ? mine : mine.filter((c) => c.addressBookIds?.[book]);
};
const exportBook = (accountId: string | null, book: string) => {
const cards = cardsOf(accountId, book);
if (!cards.length) {
toast.error(translate("There is nothing in it to export"));
return;
}
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([text], { type: "text/vcard" })); a.href = URL.createObjectURL(new Blob([cards.map(toVCard).join("")], { type: "text/vcard" }));
a.download = "contacts.vcf"; a.download = "contacts.vcf";
a.click(); a.click();
}; };
const importFile = async (f: File) => { const importFile = async (f: File, intoBookId?: string) => {
const book = bookId !== "all" ? contacts.books[bookId] : (books.find((b) => b.isDefault) ?? books[0]); const target = intoBookId && intoBookId !== "all" ? intoBookId : bookId;
const book = target !== "all" ? contacts.books[target] : (books.find((b) => b.isDefault) ?? books[0]);
if (!book) { if (!book) {
toast.error(translate("Create an address book first")); toast.error(translate("Create an address book first"));
return; return;