Put address books in the left pane, other people's included
Address book sharing was withdrawn a few hours ago on a report that it behaved like mail folder sharing. That was wrong -- it works -- and it is back, built the way Files is rather than the way it was. Three things it inherits from Files. Shared books are listed in the app's own left pane instead of behind an account switch in the profile menu. The reader's books and other people's sit under separate headings, since a book belonging to somebody else behaves differently and a single merged list would be quiet about whose contacts you are reading. And opening Contacts re-reads the session, so a book shared while the tab was open turns up without signing out and in again. The books pane the view kept to itself is gone, and with it the last module that ignored the sidebar it was given. The one thing Files does not need: shared contacts have to answer when somebody types a name into a To field, so they are loaded up front rather than when a book is opened, and they are offered by `suggest` and found by `lookupByEmail` alongside the reader's own. Their own cards win a tie, since a card someone wrote themselves should beat a colleague's copy of the same person. That is the difference between a shared book you can look at and one you can use. Cards from a shared account are held apart from the reader's rather than merged in, and keyed by account as well as id. Ids are only unique within an account -- two accounts each having a book `ab1` is ordinary -- and a flat map would have had one silently replace the other. The mock grew an address book in its shared account, with contacts in it, because none of this could be exercised otherwise. KNOWN-ISSUES records the withdrawal as the mistake it was rather than leaving it in the history looking like a finding. Mail folder sharing stays withdrawn: that one really is broken.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users } from "lucide-react";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { AddressBook } from "@/jmap/types";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
|
||||
/**
|
||||
* Re-read the session so newly shared books appear without a sign-in.
|
||||
*
|
||||
* Shared accounts arrive in the JMAP session, which is otherwise fetched once
|
||||
* and refreshed only when a state change is pushed to this tab. Opening
|
||||
* Contacts is when the answer matters, so that is when it is asked for --
|
||||
* throttled, since this is navigated to often and usually says nothing new.
|
||||
*/
|
||||
let lastRefresh = 0;
|
||||
async function refreshShares(force = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastRefresh < 30_000) return;
|
||||
lastRefresh = now;
|
||||
try {
|
||||
await useSession.getState().refresh();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await useContacts.getState().init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Address books in the app's own left pane, the reader's above and other
|
||||
* people's below.
|
||||
*
|
||||
* The two are kept plainly apart rather than merged into one list: a book that
|
||||
* belongs to somebody else behaves differently -- you cannot add to it, and
|
||||
* what you do see depends on what they granted -- and a list that hid that
|
||||
* 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"));
|
||||
const contacts = useContacts();
|
||||
const [menuBook, setMenuBook] = useState<AddressBook | null>(null);
|
||||
const [share, setShare] = useState<AddressBook | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const menu = useMenu();
|
||||
|
||||
useEffect(() => {
|
||||
void refreshShares();
|
||||
}, []);
|
||||
|
||||
if (!contacts.available) return null;
|
||||
|
||||
const own = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
const sel = contacts.selection;
|
||||
const isOn = (accountId: string | null, bookId: string) => sel.accountId === accountId && sel.bookId === bookId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="nav-section"><span>Contacts</span></div>
|
||||
<div className={`nav-item ${isOn(null, "all") ? "active" : ""}`} onClick={() => contacts.select({ accountId: null, bookId: "all" })}>
|
||||
<Users size={17} />
|
||||
<span className="grow truncate">All contacts</span>
|
||||
</div>
|
||||
|
||||
<div className="nav-section">
|
||||
<span>My address books</span>
|
||||
<button
|
||||
className="icon-btn sm"
|
||||
title="New address book"
|
||||
aria-label="New address book"
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "New address book", placeholder: "Name" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
await contacts.createBook(name.trim());
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{own.map((b) => (
|
||||
<div
|
||||
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); }}
|
||||
>
|
||||
<Book size={17} />
|
||||
<span className="grow truncate">{b.name}</span>
|
||||
{Object.keys(b.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label="Shared" />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="nav-section">
|
||||
<span>Shared with me</span>
|
||||
<button
|
||||
className="icon-btn sm"
|
||||
title="Check for new shares"
|
||||
aria-label="Check for new shares"
|
||||
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
|
||||
>
|
||||
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
{contacts.sharedBooks.map(({ accountId, accountName, book }) => (
|
||||
<div
|
||||
key={`${accountId}:${book.id}`}
|
||||
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
|
||||
onClick={() => contacts.select({ accountId, bookId: book.id })}
|
||||
title={`${book.name} — shared by ${accountName}`}
|
||||
>
|
||||
<BookOpen size={17} />
|
||||
<span className="grow truncate">{book.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{!contacts.sharedBooks.length && (
|
||||
<p className="hint" style={{ padding: "4px 12px" }}>
|
||||
{contacts.sharedLoaded ? "Nothing is shared with you." : "Looking…"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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} /> Import vCard
|
||||
<input type="file" accept=".vcf,text/vcard" 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} /> Export {sel.bookId === "all" ? "all" : "book"}</button>
|
||||
</div>
|
||||
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
|
||||
{menuBook && (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={<Pencil size={16} />}
|
||||
label="Rename"
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name });
|
||||
if (!name?.trim() || name === menuBook.name) return;
|
||||
try {
|
||||
await contacts.updateBook(menuBook.id, { name: name.trim() });
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuBook.myRights?.mayShare} onClick={() => setShare(menuBook)} />
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Trash2 size={16} />}
|
||||
label="Delete"
|
||||
disabled={menuBook.isDefault}
|
||||
onClick={async () => {
|
||||
if (!(await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "The contacts in it go too.", confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await contacts.destroyBook(menuBook.id);
|
||||
if (sel.bookId === menuBook.id) contacts.select({ accountId: null, bookId: "all" });
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
{share && <ShareDialog kind="AddressBook" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user