Merge pull request #101 from LINUXexpert-org/subscribe-shares
Add shared collections deliberately, and pick recipients from the address books
This commit is contained in:
@@ -181,7 +181,7 @@ let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate:
|
|||||||
const sieveScripts: Obj[] = [];
|
const sieveScripts: Obj[] = [];
|
||||||
/* A calendar in the shared account, so "Shared with me" and a colleague's
|
/* A calendar in the shared account, so "Shared with me" and a colleague's
|
||||||
events appearing in the grid can be exercised. Read-only, as a share is. */
|
events appearing in the grid can be exercised. Read-only, as a share is. */
|
||||||
const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
|
const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
|
||||||
const sharedEvents: Obj[] = [];
|
const sharedEvents: Obj[] = [];
|
||||||
const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
|
const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
|
||||||
const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
|
const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
|
||||||
@@ -207,7 +207,7 @@ const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, s
|
|||||||
/* A book in the shared account, so "Shared with me" and addressing a message
|
/* A book in the shared account, so "Shared with me" and addressing a message
|
||||||
from somebody else's contacts can be exercised at all. Read-only, which is
|
from somebody else's contacts can be exercised at all. Read-only, which is
|
||||||
what a share usually is. */
|
what a share usually is. */
|
||||||
const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights(false) }];
|
const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }];
|
||||||
const sharedCards: Obj[] = [
|
const sharedCards: Obj[] = [
|
||||||
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||||
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||||
@@ -732,7 +732,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
||||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||||
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
|
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
|
||||||
"Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })),
|
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||||
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
|
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
|
||||||
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
|
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
|
||||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||||
@@ -751,7 +751,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
"Principal/get": genericGet(principals),
|
"Principal/get": genericGet(principals),
|
||||||
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
|
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
|
||||||
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
|
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
|
||||||
"AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })),
|
"AddressBook/set": (a) => genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a),
|
||||||
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
||||||
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
||||||
"ContactCard/set": genericSet(cards, "cc"),
|
"ContactCard/set": genericSet(cards, "cc"),
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ interface CalendarState {
|
|||||||
/** Calendars from accounts that shared with the reader, and their events. */
|
/** Calendars from accounts that shared with the reader, and their events. */
|
||||||
loadSharedCalendars(): Promise<void>;
|
loadSharedCalendars(): Promise<void>;
|
||||||
loadSharedRange(start: Date, end: Date): Promise<void>;
|
loadSharedRange(start: Date, end: Date): Promise<void>;
|
||||||
|
/** Add a shared calendar to, or remove it from, the reader's own view. */
|
||||||
|
setSharedSubscribed(accountId: Id, calendarId: Id, subscribed: boolean): Promise<void>;
|
||||||
loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
|
loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
|
||||||
instancesIn(start: Date, end: Date): EventInstance[];
|
instancesIn(start: Date, end: Date): EventInstance[];
|
||||||
getEvent(id: Id): Promise<CalendarEvent | null>;
|
getEvent(id: Id): Promise<CalendarEvent | null>;
|
||||||
@@ -143,6 +145,26 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async setSharedSubscribed(accountId, calendarId, subscribed) {
|
||||||
|
try {
|
||||||
|
await client.call("Calendar/set", { accountId, update: { [calendarId]: { isSubscribed: subscribed } } });
|
||||||
|
} catch (err) {
|
||||||
|
set({ error: (err as Error).message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set((s) => ({
|
||||||
|
sharedCalendars: s.sharedCalendars.map((c) =>
|
||||||
|
c.accountId === accountId && c.calendar.id === calendarId ? { ...c, calendar: { ...c.calendar, isSubscribed: subscribed } } : c,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
// Its events are only fetched for calendars in view, so the windows on
|
||||||
|
// screen have to be asked again either way.
|
||||||
|
for (const key of Object.keys(get().ranges)) {
|
||||||
|
const [from, to] = key.split("|").map((n) => new Date(Number(n)));
|
||||||
|
if (from && to) void get().loadSharedRange(from, to);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/** The same window, from every account that shared a calendar. */
|
/** The same window, from every account that shared a calendar. */
|
||||||
async loadSharedRange(start, end) {
|
async loadSharedRange(start, end) {
|
||||||
const shared = get().sharedCalendars;
|
const shared = get().sharedCalendars;
|
||||||
@@ -248,8 +270,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
const accountId = k.slice(0, k.length - e.id.length - 1);
|
const accountId = k.slice(0, k.length - e.id.length - 1);
|
||||||
const calId = Object.keys(e.calendarIds ?? {})[0];
|
const calId = Object.keys(e.calendarIds ?? {})[0];
|
||||||
if (calId && hidden[sharedKey(accountId, calId)]) continue;
|
if (calId && hidden[sharedKey(accountId, calId)]) continue;
|
||||||
|
/* Stalwart hands back every calendar in an account the reader can reach,
|
||||||
|
with full rights on each, whether or not anybody meant to share it --
|
||||||
|
an account linked for its files offered its calendar too. `isSubscribed`
|
||||||
|
is the only thing separating "shared with me" from "reachable", so
|
||||||
|
nothing unsubscribed is drawn. */
|
||||||
const theirs: Record<Id, Calendar> = {};
|
const theirs: Record<Id, Calendar> = {};
|
||||||
for (const c of sharedCalendars) if (c.accountId === accountId) theirs[c.calendar.id] = c.calendar;
|
for (const c of sharedCalendars) if (c.accountId === accountId && c.calendar.isSubscribed) theirs[c.calendar.id] = c.calendar;
|
||||||
|
if (calId && !theirs[calId]) continue;
|
||||||
const inst = toInstance(e, theirs);
|
const inst = toInstance(e, theirs);
|
||||||
if (!inst) continue;
|
if (!inst) continue;
|
||||||
if (inst.end > start && inst.start < end) out.push(inst);
|
if (inst.end > start && inst.start < end) out.push(inst);
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ interface ContactsState {
|
|||||||
/** Books and cards from accounts that shared with the reader. */
|
/** Books and cards from accounts that shared with the reader. */
|
||||||
loadShared(): Promise<void>;
|
loadShared(): Promise<void>;
|
||||||
select(selection: BookSelection): void;
|
select(selection: BookSelection): void;
|
||||||
|
/** Add a shared address book to, or remove it from, the reader's own view. */
|
||||||
|
setBookSubscribed(accountId: Id, bookId: Id, subscribed: boolean): Promise<void>;
|
||||||
/** The account a card belongs to, null for the reader's own. */
|
/** The account a card belongs to, null for the reader's own. */
|
||||||
accountOfCard(id: Id): Id | null;
|
accountOfCard(id: Id): Id | null;
|
||||||
getCard(id: Id): Promise<ContactCard | null>;
|
getCard(id: Id): Promise<ContactCard | null>;
|
||||||
@@ -131,6 +133,18 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null });
|
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null });
|
||||||
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
|
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
|
||||||
|
/*
|
||||||
|
* Cards come only from books the reader has added.
|
||||||
|
*
|
||||||
|
* Stalwart hands back every book in a reachable account with full
|
||||||
|
* rights on each, shared or not -- an account linked for its files
|
||||||
|
* offered its address book too -- so `isSubscribed` is the only thing
|
||||||
|
* separating "shared with me" from "reachable". Loading the rest would
|
||||||
|
* put a stranger's contacts in the To field, which is the one place
|
||||||
|
* this must not guess.
|
||||||
|
*/
|
||||||
|
const wanted = new Set(res.list.filter((b) => b.isSubscribed).map((b) => b.id));
|
||||||
|
if (!wanted.size) continue;
|
||||||
// One page. A shared book is a colleague's contacts, not an archive,
|
// One page. A shared book is a colleague's contacts, not an archive,
|
||||||
// and the alternative is holding the reader's own list hostage to it.
|
// and the alternative is holding the reader's own list hostage to it.
|
||||||
const cardsRes = await client.chain([
|
const cardsRes = await client.chain([
|
||||||
@@ -138,7 +152,10 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"],
|
["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"],
|
||||||
]);
|
]);
|
||||||
const g = cardsRes.get("g")?.[0] as unknown as GetResponse<ContactCard>;
|
const g = cardsRes.get("g")?.[0] as unknown as GetResponse<ContactCard>;
|
||||||
for (const c of g.list) cards[sharedKey(accountId, c.id)] = c;
|
for (const c of g.list) {
|
||||||
|
if (!Object.keys(c.addressBookIds ?? {}).some((id) => wanted.has(id))) continue;
|
||||||
|
cards[sharedKey(accountId, c.id)] = c;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// An account that refuses is one that shared nothing here. Not an
|
// An account that refuses is one that shared nothing here. Not an
|
||||||
// error to show: the reader did not ask for it and cannot act on it.
|
// error to show: the reader did not ask for it and cannot act on it.
|
||||||
@@ -148,6 +165,19 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
|
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async setBookSubscribed(accountId, bookId, subscribed) {
|
||||||
|
try {
|
||||||
|
await client.call("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } });
|
||||||
|
} catch (err) {
|
||||||
|
set({ error: (err as Error).message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) {
|
||||||
|
set({ selection: { accountId: null, bookId: "all" } });
|
||||||
|
}
|
||||||
|
await get().loadShared();
|
||||||
|
},
|
||||||
|
|
||||||
select(selection) {
|
select(selection) {
|
||||||
set({ selection });
|
set({ selection });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1030,3 +1030,8 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
|||||||
.sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
.sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
.spin { animation: spin 1s linear infinite; }
|
.spin { animation: spin 1s linear infinite; }
|
||||||
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
|
||||||
|
|
||||||
|
/* The composer's To label doubles as the way into the address books. */
|
||||||
|
.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
|
||||||
|
.composer-field label .link-btn:hover { color: var(--accent); }
|
||||||
|
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Users } from "lucide-react";
|
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, X } from "lucide-react";
|
||||||
import { useCalendar } from "@/store/calendar";
|
import { useCalendar } from "@/store/calendar";
|
||||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||||
@@ -25,6 +25,8 @@ export function CalendarSidebar() {
|
|||||||
const [anchor, setAnchor] = useState(() => startOfDay(selected));
|
const [anchor, setAnchor] = useState(() => startOfDay(selected));
|
||||||
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
||||||
const menu = useMenu();
|
const menu = useMenu();
|
||||||
|
const sharedSubscribed = cal.sharedCalendars.filter((c) => c.calendar.isSubscribed);
|
||||||
|
const sharedAvailable = cal.sharedCalendars.filter((c) => !c.calendar.isSubscribed);
|
||||||
const [menuCal, setMenuCal] = useState<Calendar | null>(null);
|
const [menuCal, setMenuCal] = useState<Calendar | null>(null);
|
||||||
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
|
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
|
||||||
const [share, setShare] = useState<Calendar | null>(null);
|
const [share, setShare] = useState<Calendar | null>(null);
|
||||||
@@ -63,26 +65,52 @@ export function CalendarSidebar() {
|
|||||||
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
|
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Calendars other people shared. Separate from the reader's own, the way
|
{/* Calendars other people shared, split by whether the reader has added
|
||||||
Files and Contacts separate theirs: you cannot edit these, and which
|
them. Stalwart returns every calendar in a reachable account with full
|
||||||
of them you can see at all is somebody else's decision. Hiding one is
|
rights, so "shared with me" and "there is an account here at all" look
|
||||||
remembered under an account-qualified key, since a calendar id means
|
identical -- `isSubscribed` is the only thing that tells them apart,
|
||||||
nothing outside the account holding it. */}
|
and adding one is a deliberate act rather than a guess on our part. */}
|
||||||
{cal.sharedCalendars.length > 0 && (
|
{sharedSubscribed.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-section"><span>Shared with me</span></div>
|
<div className="nav-section"><span>Shared with me</span></div>
|
||||||
{cal.sharedCalendars.map(({ accountId, accountName, calendar: c }) => {
|
{sharedSubscribed.map(({ accountId, accountName, calendar: c }) => {
|
||||||
const key = `${accountId}:${c.id}`;
|
const key = `${accountId}:${c.id}`;
|
||||||
return (
|
return (
|
||||||
<div key={key} className={`cal-list-item ${cal.hidden[key] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(key)} title={`${c.name} — shared by ${accountName}`}>
|
<div key={key} className={`cal-list-item ${cal.hidden[key] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(key)} title={`${c.name} — shared by ${accountName}`}>
|
||||||
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
||||||
<span className="cal-name">{c.name}</span>
|
<span className="cal-name">{c.name}</span>
|
||||||
<Users size={12} className="faint" />
|
<button
|
||||||
|
className="icon-btn xs nav-more"
|
||||||
|
title="Remove from my calendar"
|
||||||
|
aria-label="Remove from my calendar"
|
||||||
|
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, false); }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{sharedAvailable.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="nav-section"><span>Available to add</span></div>
|
||||||
|
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
|
||||||
|
<div key={`${accountId}:${c.id}`} className="cal-list-item" title={`${c.name} — from ${accountName}`}>
|
||||||
|
<span className="cal-color" style={{ background: "transparent", borderColor: c.color ?? "var(--border-strong)" }} />
|
||||||
|
<span className="cal-name faint">{c.name}</span>
|
||||||
|
<button
|
||||||
|
className="icon-btn xs nav-more"
|
||||||
|
title="Add to my calendar"
|
||||||
|
aria-label="Add to my calendar"
|
||||||
|
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, true); }}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
|
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
|
||||||
{menuCal && (
|
{menuCal && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AlertTriangle, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
|
import { AlertTriangle, BookUser, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
|
||||||
import { useCompose, type Draft } from "@/store/compose";
|
import { useCompose, type Draft } from "@/store/compose";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
@@ -13,6 +13,7 @@ import { htmlToText, textToHtml } from "@/lib/text";
|
|||||||
import { isValidEmail } from "@/lib/address";
|
import { isValidEmail } from "@/lib/address";
|
||||||
import { attachmentIcon } from "../mail/MessageView";
|
import { attachmentIcon } from "../mail/MessageView";
|
||||||
import { FilePicker } from "./FilePicker";
|
import { FilePicker } from "./FilePicker";
|
||||||
|
import { RecipientPicker, type Field } from "./RecipientPicker";
|
||||||
import { useFiles } from "@/store/files";
|
import { useFiles } from "@/store/files";
|
||||||
import { keyboard } from "@/lib/keyboard";
|
import { keyboard } from "@/lib/keyboard";
|
||||||
import { useIsMobile } from "@/ui/misc";
|
import { useIsMobile } from "@/ui/misc";
|
||||||
@@ -30,6 +31,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
const addFromFiles = useCompose((s) => s.addFromFiles);
|
const addFromFiles = useCompose((s) => s.addFromFiles);
|
||||||
const filesAvailable = useFiles((s) => s.available);
|
const filesAvailable = useFiles((s) => s.available);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [addressBookOpen, setAddressBookOpen] = useState(false);
|
||||||
const removeAttachment = useCompose((s) => s.removeAttachment);
|
const removeAttachment = useCompose((s) => s.removeAttachment);
|
||||||
const setIdentity = useCompose((s) => s.setIdentity);
|
const setIdentity = useCompose((s) => s.setIdentity);
|
||||||
const insertTemplate = useCompose((s) => s.insertTemplate);
|
const insertTemplate = useCompose((s) => s.insertTemplate);
|
||||||
@@ -174,9 +176,17 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="composer-field">
|
<div className="composer-field">
|
||||||
<label htmlFor={`${key}-to`}>To</label>
|
<label htmlFor={`${key}-to`}>
|
||||||
|
{/* Opens the address books. Autocomplete only helps someone who
|
||||||
|
already knows the name they are half-way through typing. */}
|
||||||
|
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title="Choose from address books">To</button>
|
||||||
|
</label>
|
||||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} />
|
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} />
|
||||||
<span className="field-extra">
|
<span className="field-extra">
|
||||||
|
{/* Beside Cc and Bcc, because that is where someone looks when
|
||||||
|
they are thinking about who the message goes to. The label
|
||||||
|
opens it too, for anyone who tries that first. */}
|
||||||
|
<button type="button" onClick={() => setAddressBookOpen(true)} title="Choose from address books" aria-label="Choose from address books"><BookUser size={15} /></button>
|
||||||
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
|
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
|
||||||
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
|
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
|
||||||
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
|
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
|
||||||
@@ -244,6 +254,20 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
||||||
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
|
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
|
||||||
</Popover>
|
</Popover>
|
||||||
|
{addressBookOpen && (
|
||||||
|
<RecipientPicker
|
||||||
|
onPick={(field: Field, addresses) => {
|
||||||
|
// Added to whatever is already there, and the field is opened if
|
||||||
|
// it was hidden -- picking a Bcc should not put one somewhere
|
||||||
|
// the writer cannot see it.
|
||||||
|
const existing = field === "to" ? d.to : field === "cc" ? d.cc : d.bcc;
|
||||||
|
const merged = [...existing];
|
||||||
|
for (const a of addresses) if (!merged.some((x) => x.email.toLowerCase() === a.email.toLowerCase())) merged.push(a);
|
||||||
|
patch({ [field]: merged, ...(field === "cc" ? { showCc: true } : field === "bcc" ? { showBcc: true } : {}) });
|
||||||
|
}}
|
||||||
|
onClose={() => setAddressBookOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{pickerOpen && <FilePicker onPick={(picked) => void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />}
|
{pickerOpen && <FilePicker onPick={(picked) => void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />}
|
||||||
{canSchedule && scheduleOpen && (
|
{canSchedule && scheduleOpen && (
|
||||||
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
|
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Book, BookOpen, Search, Users, X } from "lucide-react";
|
||||||
|
import { Dialog } from "@/ui/dialog";
|
||||||
|
import { useContacts } from "@/store/contacts";
|
||||||
|
import { contactDisplayName, contactEmails } from "@/lib/contacts";
|
||||||
|
import type { ContactCard, EmailAddress } from "@/jmap/types";
|
||||||
|
|
||||||
|
export type Field = "to" | "cc" | "bcc";
|
||||||
|
|
||||||
|
/** One selectable address: a card can carry several, so the address is the unit. */
|
||||||
|
interface Row {
|
||||||
|
key: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
book: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose recipients by looking through the address books.
|
||||||
|
*
|
||||||
|
* Autocomplete answers "finish this name for me", which is only useful when the
|
||||||
|
* writer already knows who they want. This answers the other question -- who is
|
||||||
|
* there? -- so the books can be read rather than recalled, and several people
|
||||||
|
* picked in one pass rather than typed one at a time.
|
||||||
|
*
|
||||||
|
* Each address is its own row, not each person: someone with a work address and
|
||||||
|
* a personal one is a choice to make, and a picker that offered the card and
|
||||||
|
* quietly took the first address would make it for them.
|
||||||
|
*
|
||||||
|
* Shared books are in here on the same footing as the reader's own, which is
|
||||||
|
* the point of having added them -- with the account named, so it is never a
|
||||||
|
* mystery whose list a name came from.
|
||||||
|
*/
|
||||||
|
export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, addresses: EmailAddress[]) => void; onClose: () => void }) {
|
||||||
|
const contacts = useContacts();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [bookKey, setBookKey] = useState<string>("all");
|
||||||
|
const [picked, setPicked] = useState<Record<string, Row>>({});
|
||||||
|
|
||||||
|
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed);
|
||||||
|
const ownBooks = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const out: Row[] = [];
|
||||||
|
const push = (card: ContactCard, book: string, keyPrefix: string) => {
|
||||||
|
for (const a of contactEmails(card)) {
|
||||||
|
if (!a.email) continue;
|
||||||
|
out.push({ key: `${keyPrefix}:${card.id}:${a.email}`, name: a.name ?? contactDisplayName(card), email: a.email, book });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (bookKey === "all" || !bookKey.includes(":")) {
|
||||||
|
for (const c of Object.values(contacts.cards)) {
|
||||||
|
if (bookKey !== "all" && !c.addressBookIds?.[bookKey]) continue;
|
||||||
|
push(c, contacts.books[Object.keys(c.addressBookIds ?? {})[0] ?? ""]?.name ?? "Contacts", "own");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bookKey === "all" || bookKey.includes(":")) {
|
||||||
|
for (const [key, card] of Object.entries(contacts.sharedCards)) {
|
||||||
|
const accountId = key.slice(0, key.length - card.id.length - 1);
|
||||||
|
const inBook = subscribed.find((b) => b.accountId === accountId && card.addressBookIds?.[b.book.id]);
|
||||||
|
if (!inBook) continue;
|
||||||
|
if (bookKey !== "all" && bookKey !== `${accountId}:${inBook.book.id}`) continue;
|
||||||
|
push(card, `${inBook.book.name} · ${inBook.accountName}`, accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
const filtered = needle
|
||||||
|
? out.filter((r) => `${r.name ?? ""} ${r.email}`.toLowerCase().includes(needle))
|
||||||
|
: out;
|
||||||
|
return filtered.sort((a, b) => (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||||
|
}, [contacts.cards, contacts.sharedCards, contacts.books, subscribed, bookKey, q]);
|
||||||
|
|
||||||
|
const chosen = Object.values(picked);
|
||||||
|
const toggle = (r: Row) =>
|
||||||
|
setPicked((p) => {
|
||||||
|
const next = { ...p };
|
||||||
|
if (next[r.key]) delete next[r.key];
|
||||||
|
else next[r.key] = r;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = (field: Field) => {
|
||||||
|
onPick(field, chosen.map((r) => ({ name: r.name, email: r.email })));
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title="Choose recipients"
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>Bcc</button>
|
||||||
|
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>Cc</button>
|
||||||
|
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
|
||||||
|
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="row gap-8" style={{ marginBottom: 10 }}>
|
||||||
|
{/* Same shape as the contact list's own search box. */}
|
||||||
|
<label className="search-input grow" style={{ height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
|
||||||
|
<Search size={15} className="faint" />
|
||||||
|
<input
|
||||||
|
className="grow"
|
||||||
|
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
|
||||||
|
placeholder="Search names and addresses"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label="Address book">
|
||||||
|
<option value="all">All address books</option>
|
||||||
|
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||||
|
{subscribed.map((b) => (
|
||||||
|
<option key={`${b.accountId}:${b.book.id}`} value={`${b.accountId}:${b.book.id}`}>
|
||||||
|
{b.book.name} · {b.accountName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chosen.length > 0 && (
|
||||||
|
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
|
||||||
|
{chosen.map((r) => (
|
||||||
|
<button key={r.key} className="chip" onClick={() => toggle(r)} title="Remove">
|
||||||
|
{r.name ?? r.email} <X size={12} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
|
||||||
|
{!rows.length ? (
|
||||||
|
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
|
||||||
|
) : (
|
||||||
|
rows.map((r) => (
|
||||||
|
<label key={r.key} className="menu-item" style={{ cursor: "pointer" }}>
|
||||||
|
<input type="checkbox" checked={Boolean(picked[r.key])} onChange={() => toggle(r)} />
|
||||||
|
{r.book.includes("·") ? <BookOpen size={16} className="faint" /> : <Book size={16} className="faint" />}
|
||||||
|
<span className="grow truncate">
|
||||||
|
{r.name ?? r.email}
|
||||||
|
{r.name && <span className="hint"> · {r.email}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="hint nowrap">{r.book}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ownBooks.length && !subscribed.length && (
|
||||||
|
<p className="hint" style={{ marginTop: 8 }}><Users size={12} /> No address books yet.</p>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users } from "lucide-react";
|
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users, X } from "lucide-react";
|
||||||
import { useContacts } from "@/store/contacts";
|
import { useContacts } from "@/store/contacts";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import type { AddressBook } from "@/jmap/types";
|
import type { AddressBook } from "@/jmap/types";
|
||||||
@@ -58,6 +58,8 @@ export function ContactsSidebar() {
|
|||||||
const own = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
const own = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||||
const sel = contacts.selection;
|
const sel = contacts.selection;
|
||||||
const isOn = (accountId: string | null, bookId: string) => sel.accountId === accountId && sel.bookId === bookId;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -110,7 +112,7 @@ export function ContactsSidebar() {
|
|||||||
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{contacts.sharedBooks.map(({ accountId, accountName, book }) => (
|
{subscribed.map(({ accountId, accountName, book }) => (
|
||||||
<div
|
<div
|
||||||
key={`${accountId}:${book.id}`}
|
key={`${accountId}:${book.id}`}
|
||||||
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
|
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
|
||||||
@@ -119,14 +121,45 @@ export function ContactsSidebar() {
|
|||||||
>
|
>
|
||||||
<BookOpen size={17} />
|
<BookOpen size={17} />
|
||||||
<span className="grow truncate">{book.name}</span>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!contacts.sharedBooks.length && (
|
{!subscribed.length && (
|
||||||
<p className="hint" style={{ padding: "4px 12px" }}>
|
<p className="hint" style={{ padding: "4px 12px" }}>
|
||||||
{contacts.sharedLoaded ? "Nothing is shared with you." : "Looking…"}
|
{contacts.sharedLoaded ? "Nothing added yet." : "Looking…"}
|
||||||
</p>
|
</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. */}
|
{/* Import and export lived in the pane this replaced. */}
|
||||||
<div style={{ padding: "12px 8px" }} className="col gap-8">
|
<div style={{ padding: "12px 8px" }} className="col gap-8">
|
||||||
<label className="btn btn-sm btn-block">
|
<label className="btn btn-sm btn-block">
|
||||||
|
|||||||
Reference in New Issue
Block a user