Merge pull request #226 from Coffey-Labs/feat/contacts-menu-parity

Give the address books the menus the calendars have
This commit is contained in:
Coffey Labs
2026-09-02 09:08:40 -07:00
committed by GitHub
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.",
"Choose from address books": "Aus Adressbüchern wählen",
"Import vCard": "vCard importieren",
"Export all": "Alle exportieren",
"Export book": "Adressbuch exportieren",
"Export all contacts": "Alle Kontakte exportieren",
"Export address book": "Dieses Adressbuch exportieren",
"Import contacts…": "Kontakte importieren…",
"Contact options": "Kontaktoptionen",
"Address book options": "Adressbuchoptionen",
"Email group": "Gruppe anschreiben",
"Email everyone": "Alle anschreiben",
"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.",
"Choose from address books": "Elegir de las libretas de direcciones",
"Import vCard": "Importar una vCard",
"Export all": "Exportar todo",
"Export book": "Exportar la libreta",
"Export all contacts": "Exportar todos los contactos",
"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 everyone": "Escribir a todos",
"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.",
"Choose from address books": "Choisir dans les carnets d'adresses",
"Import vCard": "Importer une vCard",
"Export all": "Tout exporter",
"Export book": "Exporter le carnet",
"Export all contacts": "Exporter tous les contacts",
"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 everyone": "Écrire à tous",
"Members": "Membres",
+5 -2
View File
@@ -354,8 +354,11 @@ export const catalog: Catalog = {
"No address books yet.": "アドレス帳がまだありません。",
"Choose from address books": "アドレス帳から選択",
"Import vCard": "vCard をインポート",
"Export all": "すべてエクスポート",
"Export book": "アドレス帳をエクスポート",
"Export all contacts": "すべての連絡先をエクスポート",
"Export address book": "このアドレス帳をエクスポート",
"Import contacts…": "連絡先をインポート…",
"Contact options": "連絡先のオプション",
"Address book options": "アドレス帳のオプション",
"Email group": "グループにメール",
"Email everyone": "全員にメール",
"Members": "メンバー",
+5 -2
View File
@@ -351,8 +351,11 @@ export const catalog: Catalog = {
"No address books yet.": "Nog geen adresboeken.",
"Choose from address books": "Kiezen uit adresboeken",
"Import vCard": "vCard importeren",
"Export all": "Alles exporteren",
"Export book": "Adresboek exporteren",
"Export all contacts": "Alle contacten exporteren",
"Export address book": "Dit adresboek exporteren",
"Import contacts…": "Contacten importeren…",
"Contact options": "Contactopties",
"Address book options": "Adresboekopties",
"Email group": "Groep e-mailen",
"Email everyone": "Iedereen e-mailen",
"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.",
"Choose from address books": "Escolher nos catálogos de endereços",
"Import vCard": "Importar um vCard",
"Export all": "Exportar tudo",
"Export book": "Exportar o catálogo",
"Export all contacts": "Exportar todos os contatos",
"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 everyone": "Escrever para todos",
"Members": "Membros",
+5 -2
View File
@@ -357,8 +357,11 @@ export const catalog: Catalog = {
"No address books yet.": "Адресных книг пока нет.",
"Choose from address books": "Выбрать из адресных книг",
"Import vCard": "Импорт vCard",
"Export all": "Экспортировать всё",
"Export book": "Экспортировать книгу",
"Export all contacts": "Экспортировать все контакты",
"Export address book": "Экспортировать эту адресную книгу",
"Import contacts…": "Импортировать контакты…",
"Contact options": "Параметры контактов",
"Address book options": "Параметры адресной книги",
"Email group": "Написать группе",
"Email everyone": "Написать всем",
"Members": "Участники",
+5 -2
View File
@@ -351,8 +351,11 @@ export const catalog: Catalog = {
"No address books yet.": "Адресних книг поки немає.",
"Choose from address books": "Вибрати з адресних книг",
"Import vCard": "Імпорт vCard",
"Export all": "Експортувати все",
"Export book": "Експортувати книгу",
"Export all contacts": "Експортувати всі контакти",
"Export address book": "Експортувати цю адресну книгу",
"Import contacts…": "Імпортувати контакти…",
"Contact options": "Параметри контактів",
"Address book options": "Параметри адресної книги",
"Email group": "Написати групі",
"Email everyone": "Написати всім",
"Members": "Учасники",
+5 -2
View File
@@ -353,8 +353,11 @@ export const catalog: Catalog = {
"No address books yet.": "还没有通讯录。",
"Choose from address books": "从通讯录中选择",
"Import vCard": "导入 vCard",
"Export all": "全部导出",
"Export book": "导出通讯录",
"Export all contacts": "导出所有联系人",
"Export address book": "导出通讯录",
"Import contacts…": "导入联系人…",
"Contact options": "联系人选项",
"Address book options": "通讯录选项",
"Email group": "给该群组写邮件",
"Email everyone": "给所有人写邮件",
"Members": "成员",
+86 -26
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Book, BookOpen, Download, MoreVertical, 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";
@@ -41,13 +41,39 @@ async function refreshShares(force = false): Promise<void> {
* 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"));
/* 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);
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 [refreshing, setRefreshing] = useState(false);
const menu = useMenu();
@@ -71,9 +97,14 @@ export function ContactsSidebar() {
return (
<>
<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} />
<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 className="nav-section">
@@ -100,11 +131,12 @@ export function ContactsSidebar() {
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); }}
onContextMenu={(e) => openMenuAt(e, { kind: "own", book: b })}
>
<Book size={17} />
<span className="grow truncate">{b.name}</span>
{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>
))}
@@ -125,17 +157,14 @@ export function ContactsSidebar() {
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
onClick={() => contacts.select({ accountId, bookId: book.id })}
title={`${book.name} — shared by ${accountName}`}
onContextMenu={(e) => openMenuAt(e, { kind: "shared", accountId, book })}
>
<BookOpen size={17} />
<span className="grow truncate">{book.name}</span>
<button
className="icon-btn sm"
title={t("Remove from my contacts")}
aria-label={t("Remove from my contacts")}
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, false); }}
>
<X size={13} />
</button>
{/* 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. */}
<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>
</div>
))}
{!subscribed.length && (
@@ -167,18 +196,49 @@ export function ContactsSidebar() {
</>
)}
{/* 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} /> {t("Import contacts")}
<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 = ""; }} />
</label>
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> {sel.bookId === "all" ? t("Export all") : t("Export book")}</button>
</div>
<input
ref={fileRef}
type="file"
accept=".vcf,.vcard,.ldif,.ldi,text/vcard,text/directory"
hidden
onChange={(e) => { const f = e.target.files?.[0]; const into = importInto.current; if (f && into) onImport(f, into); e.target.value = ""; }}
/>
<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 && (
<>
<MenuSep />
<MenuItem
icon={<Pencil size={16} />}
label={t("Rename")}
+40 -7
View File
@@ -32,8 +32,20 @@ export function ContactsView({ id }: { id?: string }) {
useEffect(() => {
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:contacts-import", onImport);
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>;
}
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");
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.click();
};
const importFile = async (f: File) => {
const book = bookId !== "all" ? contacts.books[bookId] : (books.find((b) => b.isDefault) ?? books[0]);
const importFile = async (f: File, intoBookId?: string) => {
const target = intoBookId && intoBookId !== "all" ? intoBookId : bookId;
const book = target !== "all" ? contacts.books[target] : (books.find((b) => b.isDefault) ?? books[0]);
if (!book) {
toast.error(translate("Create an address book first"));
return;