Revoking a share meant opening the share dialog, removing each person from it in turn, and saving. That is the right tool for changing who has access and the wrong one for withdrawing it altogether, which is the more urgent of the two and the one someone is likely to want in a hurry. Both now offer "Stop sharing" in the context menu, which clears the lot after a confirmation saying how many people lose access. It appears only when there is something to revoke, so the menu says whether a thing is shared as well as offering to change it. A calendar also says it is shared now. Address books have carried that badge since they gained sharing; calendars never did, so the only way to find out was to open the dialog and look -- which for the owner of a dozen calendars means opening a dozen dialogs. Both go through the existing update paths, so a server that refuses is reported rather than swallowed. Verified against the mock, both kinds: sharing one shows the badge and adds the entry, confirming clears `shareWith`, the badge goes, and the entry disappears with it since there is no longer anything to stop.
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Book, BookOpen, Download, 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";
|
|
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 settings = useSettings((s) => s.settings);
|
|
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;
|
|
/* Added if the server says so or the reader's settings do -- Stalwart will
|
|
not take the flag on a book shared read-only, so the settings carry it. */
|
|
const added = new Set(settings.addedShares);
|
|
const isAdded = (accountId: string, bookId: string) => added.has(`${accountId}:${bookId}`);
|
|
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed || isAdded(b.accountId, b.book.id));
|
|
const available = contacts.sharedBooks.filter((b) => !(b.book.isSubscribed || isAdded(b.accountId, b.book.id)));
|
|
|
|
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)} />
|
|
{/* Revoking the lot, rather than removing people one at a time in
|
|
the dialog. Only shown when there is something to revoke. */}
|
|
{Object.keys(menuBook.shareWith ?? {}).length > 0 && (
|
|
<MenuItem
|
|
icon={<UserMinus size={16} />}
|
|
label="Stop sharing"
|
|
disabled={!menuBook.myRights?.mayShare}
|
|
onClick={async () => {
|
|
const who = Object.keys(menuBook.shareWith ?? {}).length;
|
|
if (!(await confirmDialog({
|
|
title: `Stop sharing “${menuBook.name}”?`,
|
|
message: `${who === 1 ? "One person" : `${who} people`} will lose access. The contacts in it are not affected.`,
|
|
confirmLabel: "Stop sharing",
|
|
danger: true,
|
|
}))) return;
|
|
try {
|
|
await contacts.updateBook(menuBook.id, { shareWith: null });
|
|
toast.success("No longer shared");
|
|
} catch (err) {
|
|
toast.error((err as Error).message);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
<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)} />}
|
|
</>
|
|
);
|
|
}
|