Add a shared calendar or address book, rather than being given it
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.
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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { 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