diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 9fc828b..44f9a69 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 4b5d042..2d96789 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 49ae055..d0deea0 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -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", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 17420e1..ccbd6ee 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -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": "メンバー", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index e0be62e..a7323fa 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -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", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index ac8752c..0f9b62e 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -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", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 08794ce..c435b66 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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": "Участники", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 840a38d..d167333 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -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": "Учасники", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 97b0fda..edcba5f 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -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": "成员", diff --git a/web/src/views/contacts/ContactsSidebar.tsx b/web/src/views/contacts/ContactsSidebar.tsx index 7c6ea7b..faa738e 100644 --- a/web/src/views/contacts/ContactsSidebar.tsx +++ b/web/src/views/contacts/ContactsSidebar.tsx @@ -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 { * 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(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(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(null); + const importInto = useRef(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(null); const [refreshing, setRefreshing] = useState(false); const menu = useMenu(); @@ -71,9 +97,14 @@ export function ContactsSidebar() { return ( <>
{t("Contacts")}
-
contacts.select({ accountId: null, bookId: "all" })}> +
contacts.select({ accountId: null, bookId: "all" })} + onContextMenu={(e) => openMenuAt(e, { kind: "all" })} + > {t("All contacts")} +
@@ -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 })} > {b.name} {Object.keys(b.shareWith ?? {}).length > 0 && } +
))} @@ -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 })} > {book.name} - + {/* 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. */} +
))} {!subscribed.length && ( @@ -167,18 +196,49 @@ export function ContactsSidebar() { )} - {/* Import and export lived in the pane this replaced. */} -
- - -
+ { const f = e.target.files?.[0]; const into = importInto.current; if (f && into) onImport(f, into); e.target.value = ""; }} + /> - + + {target && ( + <> + {/* Exporting is the one thing every row can do -- your own books, + somebody else's, and the whole lot together. */} + } + 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" && ( + } + label={t("Import contacts…")} + onClick={() => { importInto.current = target.kind === "all" ? "all" : target.book.id; fileRef.current?.click(); }} + /> + )} + {target.kind === "shared" && ( + <> + + } + label={t("Remove from my contacts")} + onClick={() => void contacts.setBookSubscribed(target.accountId, target.book.id, false)} + /> + + )} + + )} {menuBook && ( <> + } label={t("Rename")} diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx index 47a5022..db40ce1 100644 --- a/web/src/views/contacts/ContactsView.tsx +++ b/web/src/views/contacts/ContactsView.tsx @@ -32,8 +32,20 @@ export function ContactsView({ id }: { id?: string }) { useEffect(() => { const onNew = () => setEditing({}); - const onImport = (ev: Event) => { const f = (ev as CustomEvent).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
} title={translate("Contacts are not available")}>{translate("This account does not have the JMAP contacts capability.")}
; } - 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;