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.", "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;