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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 d’adresses",
|
||||
"Import contacts…": "Importer des contacts…",
|
||||
"Contact options": "Options des contacts",
|
||||
"Address book options": "Options du carnet d’adresses",
|
||||
"Email group": "Écrire au groupe",
|
||||
"Email everyone": "Écrire à tous",
|
||||
"Members": "Membres",
|
||||
|
||||
@@ -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": "メンバー",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Участники",
|
||||
|
||||
@@ -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": "Учасники",
|
||||
|
||||
@@ -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": "成员",
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user