An account linked for its files also offered its calendar and its address
book, and neither had been shared. That was not ihasmail inventing them:
asked about the other account, the live 0.16.19 returns every calendar
and every book it holds, each with full rights -- read, write, share,
delete, all true. There is nothing in the rights to tell "shared with me"
from "reachable at all", because the server does not distinguish them.
`isSubscribed` does, and it is the field JMAP has for exactly this: it
came back false on all of them. So a shared calendar or book is listed
under "Shared with me" once the reader has added it, and under "Available
to add" until then, with one button either way.
Nothing unsubscribed contributes anything. A calendar that has not been
added draws no events, and a book that has not been added lends no cards
to the To field -- which is the one that mattered most, since it is the
difference between offering a colleague's contacts and offering a
stranger's without anyone having asked.
The mock's shared calendar and address book now arrive unsubscribed, the
way the real server hands them over, so the adding is exercised rather
than skipped; and its `Calendar/set` and `AddressBook/set` route by
account, since subscribing to somebody else's is a write to their
account and the mock had nowhere to put it.
Verified against the mock: the shared calendar sits under "Available to
add" with no events in the grid, adding it moves it to "Shared with me"
and its events appear, removing it undoes both; and `suggest("katherine")`
finds nothing until the shared book is added, then finds her.
212 lines
8.7 KiB
TypeScript
212 lines
8.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users, X } 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;
|
|
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed);
|
|
const available = contacts.sharedBooks.filter((b) => !b.book.isSubscribed);
|
|
|
|
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>
|
|
{subscribed.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>
|
|
<button
|
|
className="icon-btn sm"
|
|
title="Remove from my contacts"
|
|
aria-label="Remove from my contacts"
|
|
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, false); }}
|
|
>
|
|
<X size={13} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{!subscribed.length && (
|
|
<p className="hint" style={{ padding: "4px 12px" }}>
|
|
{contacts.sharedLoaded ? "Nothing added yet." : "Looking…"}
|
|
</p>
|
|
)}
|
|
|
|
{/* Stalwart returns every book in a reachable account with full rights,
|
|
shared or not, so adding one is the reader's decision rather than a
|
|
guess made on their behalf. */}
|
|
{available.length > 0 && (
|
|
<>
|
|
<div className="nav-section"><span>Available to add</span></div>
|
|
{available.map(({ accountId, accountName, book }) => (
|
|
<div key={`${accountId}:${book.id}`} className="nav-item" title={`${book.name} — from ${accountName}`}>
|
|
<BookOpen size={17} className="faint" />
|
|
<span className="grow truncate faint">{book.name}</span>
|
|
<button
|
|
className="icon-btn sm"
|
|
title="Add to my contacts"
|
|
aria-label="Add to my contacts"
|
|
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, true); }}
|
|
>
|
|
<Plus size={13} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* 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)} />}
|
|
</>
|
|
);
|
|
}
|