Shared calendars in the calendar, and no more account switcher
Three things from using it on two real accounts. A calendar shared with you never appeared. Nothing was wrong with the share -- the calendar had nowhere to be shown. Calendars loaded from one account and one only, so the sharer's were reachable solely by switching the whole app to their account, which is the door being closed below. They now sit under "Shared with me" beside the reader's own, in their own colour, with their events in the grid and a click to hide them like any other calendar. Their events go through `instancesIn`, the one funnel every view already reads, so month, week, day and agenda got them without being touched. Events and calendars from another account are keyed by account as well as id, and hiding one is remembered under the same key: an id means nothing outside the account holding it, and two accounts sharing an id is ordinary rather than unlucky. An account that shared nothing was listed in Files as though it had. Every non-personal account was offered on the reasoning that its folders could speak for themselves -- but an account whose *calendar* was shared has no folders to speak with, and appeared as an invitation to open an empty pane. Each is now asked for one file before being listed, and silence is taken for an answer. And the account switcher is gone from the profile menu. It existed to reach what other people shared and was the wrong door: it moved the whole app to somebody else's account, and since Stalwart advertises every capability on a shared account, mail, calendar and contacts went with it and were refused. Everything it was for is now in the module the share belongs to, found without anyone needing to know an account was involved. What this does not prove is that Stalwart delivers a calendar share at all. The mock says the client handles one, which is the half that was missing; whether the server behaves like address books, which work, or like mail folders, which do not, needs the two accounts again.
This commit is contained in:
@@ -179,6 +179,12 @@ const identities: Obj[] = [
|
||||
];
|
||||
let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null };
|
||||
const sieveScripts: Obj[] = [];
|
||||
/* 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. */
|
||||
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 sharedEvents: Obj[] = [];
|
||||
const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
|
||||
const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
|
||||
const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
|
||||
function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
|
||||
const events: Obj[] = [];
|
||||
@@ -191,6 +197,9 @@ const events: Obj[] = [];
|
||||
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
|
||||
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
|
||||
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
|
||||
// Two in the shared account, so a colleague's calendar has something in it.
|
||||
sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
|
||||
sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null });
|
||||
}
|
||||
const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
|
||||
const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write });
|
||||
@@ -722,10 +731,10 @@ const handlers: Record<string, Handler> = {
|
||||
"SieveScript/get": genericGet(sieveScripts),
|
||||
"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 }),
|
||||
"Calendar/get": genericGet(calendars),
|
||||
"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 })),
|
||||
"CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }),
|
||||
"CalendarEvent/get": genericGet(events),
|
||||
"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),
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||
// #26 and #30 reached a live server unnoticed — so it now does both.
|
||||
|
||||
+106
-2
@@ -15,10 +15,26 @@ export interface EventInstance {
|
||||
calendar: Calendar | undefined;
|
||||
}
|
||||
|
||||
/** A calendar somebody else shared, and the account it lives in. */
|
||||
export interface SharedCalendar {
|
||||
accountId: Id;
|
||||
accountName: string;
|
||||
calendar: Calendar;
|
||||
}
|
||||
|
||||
/** Shared events are keyed by account too: ids only differ within an account. */
|
||||
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`;
|
||||
|
||||
interface CalendarState {
|
||||
accountId: Id | null;
|
||||
available: boolean;
|
||||
calendars: Record<Id, Calendar>;
|
||||
/** Calendars shared with the reader, from every non-personal account. */
|
||||
sharedCalendars: SharedCalendar[];
|
||||
/** Their events, keyed by account and id. See `sharedKey`. */
|
||||
sharedEvents: Record<string, CalendarEvent>;
|
||||
/** Which shared keys each loaded window holds, alongside `ranges`. */
|
||||
sharedRanges: Record<string, string[]>;
|
||||
events: Record<Id, CalendarEvent>;
|
||||
/** Loaded ranges keyed "start|end" → event ids */
|
||||
ranges: Record<string, Id[]>;
|
||||
@@ -29,6 +45,9 @@ interface CalendarState {
|
||||
|
||||
init(): Promise<void>;
|
||||
loadCalendars(): Promise<void>;
|
||||
/** Calendars from accounts that shared with the reader, and their events. */
|
||||
loadSharedCalendars(): Promise<void>;
|
||||
loadSharedRange(start: Date, end: Date): Promise<void>;
|
||||
loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
|
||||
instancesIn(start: Date, end: Date): EventInstance[];
|
||||
getEvent(id: Id): Promise<CalendarEvent | null>;
|
||||
@@ -65,6 +84,9 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
accountId: null,
|
||||
available: false,
|
||||
calendars: {},
|
||||
sharedCalendars: [],
|
||||
sharedEvents: {},
|
||||
sharedRanges: {},
|
||||
events: {},
|
||||
ranges: {},
|
||||
loading: false,
|
||||
@@ -73,12 +95,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
hidden: {},
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.calendars);
|
||||
// The reader's own: a shared calendar is shown beside theirs, not instead.
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.calendars);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.calendars));
|
||||
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
||||
set({ available });
|
||||
if (!available) return;
|
||||
await get().loadCalendars();
|
||||
void get().loadSharedCalendars();
|
||||
try {
|
||||
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
|
||||
set({ identities: res.list });
|
||||
@@ -87,6 +111,67 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* Calendars other people shared, and the events in them.
|
||||
*
|
||||
* Kept apart from the reader's own and keyed by account, for the reason ids
|
||||
* force: they are unique only within an account. Loaded from the same window
|
||||
* the reader is looking at, so a colleague's calendar fills in beside their
|
||||
* own rather than after a separate wait.
|
||||
*
|
||||
* An account that answers with no calendars is simply not listed. Sharing a
|
||||
* file does not make somebody's calendar worth a heading.
|
||||
*/
|
||||
async loadSharedCalendars() {
|
||||
const session = useSession.getState();
|
||||
const own = session.ownAccountFor(CAP.calendars);
|
||||
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
|
||||
const found: SharedCalendar[] = [];
|
||||
for (const [accountId, account] of accounts) {
|
||||
try {
|
||||
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null });
|
||||
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
set({ sharedCalendars: found });
|
||||
// Fill in whatever windows are already on screen.
|
||||
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. */
|
||||
async loadSharedRange(start, end) {
|
||||
const shared = get().sharedCalendars;
|
||||
if (!shared.length) return;
|
||||
const key = `${start.getTime()}|${end.getTime()}`;
|
||||
const tz = settings().timeZone ?? browserTimeZone;
|
||||
const accounts = [...new Set(shared.map((c) => c.accountId))];
|
||||
const ids: string[] = [];
|
||||
const events: Record<string, CalendarEvent> = {};
|
||||
for (const accountId of accounts) {
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["CalendarEvent/query", { accountId, filter: { after: toLocalDateTime(start), before: toLocalDateTime(end) }, timeZone: tz, sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"],
|
||||
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS, timeZone: tz }, "g"],
|
||||
]);
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>;
|
||||
for (const e of g.list) {
|
||||
const k = sharedKey(accountId, e.id);
|
||||
events[k] = e;
|
||||
ids.push(k);
|
||||
}
|
||||
} catch {
|
||||
// One account refusing must not empty the calendar of the others.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
set((s) => ({ sharedEvents: { ...s.sharedEvents, ...events }, sharedRanges: { ...s.sharedRanges, [key]: ids } }));
|
||||
},
|
||||
|
||||
async loadCalendars() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
@@ -131,13 +216,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
for (const e of g.list) events[e.id] = e;
|
||||
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null };
|
||||
});
|
||||
void get().loadSharedRange(start, end);
|
||||
} catch (err) {
|
||||
set({ loading: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
instancesIn(start, end) {
|
||||
const { events, ranges, calendars, hidden } = get();
|
||||
const { events, ranges, calendars, hidden, sharedEvents, sharedRanges, sharedCalendars } = get();
|
||||
const ids = new Set<Id>();
|
||||
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
|
||||
const out: EventInstance[] = [];
|
||||
@@ -150,6 +236,24 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
if (!inst) continue;
|
||||
if (inst.end > start && inst.start < end) out.push(inst);
|
||||
}
|
||||
/* Shared events go through the same funnel, so every view gets them
|
||||
without knowing they exist. Their calendars are looked up per account:
|
||||
a shared calendar id means nothing outside the account holding it, and
|
||||
hiding one is remembered under the same account-qualified key. */
|
||||
const sharedKeys = new Set<string>();
|
||||
for (const list of Object.values(sharedRanges)) for (const k of list) sharedKeys.add(k);
|
||||
for (const k of sharedKeys) {
|
||||
const e = sharedEvents[k];
|
||||
if (!e) continue;
|
||||
const accountId = k.slice(0, k.length - e.id.length - 1);
|
||||
const calId = Object.keys(e.calendarIds ?? {})[0];
|
||||
if (calId && hidden[sharedKey(accountId, calId)]) continue;
|
||||
const theirs: Record<Id, Calendar> = {};
|
||||
for (const c of sharedCalendars) if (c.accountId === accountId) theirs[c.calendar.id] = c.calendar;
|
||||
const inst = toInstance(e, theirs);
|
||||
if (!inst) continue;
|
||||
if (inst.end > start && inst.start < end) out.push(inst);
|
||||
}
|
||||
out.sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
|
||||
return out;
|
||||
},
|
||||
|
||||
+19
-6
@@ -133,14 +133,27 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
* Which accounts hold shared files cannot be worked out from capabilities:
|
||||
* Stalwart advertises the whole set on a shared account -- mail, calendars,
|
||||
* contacts and the rest -- identical to a personal one, whatever was
|
||||
* actually shared (checked on 0.16.19, 2026-08-27). So every account that
|
||||
* is not the reader's own is offered, and what it really holds is settled
|
||||
* by asking it for its folders and showing what comes back.
|
||||
* actually shared (checked on 0.16.19, 2026-08-27). So each one is asked
|
||||
* for its files, and only the ones that answer with any are listed.
|
||||
*
|
||||
* Listing them all and letting the folders speak for themselves was the
|
||||
* first attempt, and it put an account holding nothing at all under
|
||||
* "Shared with me" -- an invitation to open an empty pane, offered by an
|
||||
* account whose calendar or contacts were the thing actually shared. An
|
||||
* account that shares no files does not belong in a list of shared files.
|
||||
*/
|
||||
const s = session.session;
|
||||
const sharedAccounts = Object.entries(s?.accounts ?? {})
|
||||
.filter(([, a]) => a.isPersonal === false)
|
||||
.map(([id, a]) => ({ id, name: a.name }));
|
||||
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
|
||||
const sharedAccounts: SharedAccount[] = [];
|
||||
for (const [id, a] of candidates) {
|
||||
try {
|
||||
const res = await client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 });
|
||||
if (res.ids.length) sharedAccounts.push({ id, name: a.name });
|
||||
} catch {
|
||||
// Refused means nothing here is ours to see, which is the same answer.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Stay where the reader is if they are reading a share that still exists.
|
||||
const browsing = get().accountId;
|
||||
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
|
||||
|
||||
+12
-16
@@ -6,7 +6,7 @@ import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { MailboxTree } from "./mail/MailboxTree";
|
||||
import { FilesTree } from "./files/FilesTree";
|
||||
@@ -14,7 +14,6 @@ import { ContactsSidebar } from "./contacts/ContactsSidebar";
|
||||
import { CalendarSidebar } from "./calendar/CalendarSidebar";
|
||||
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { CAP } from "@/jmap/client";
|
||||
|
||||
const PUSH_LABEL = {
|
||||
connected: "Live updates connected",
|
||||
@@ -32,8 +31,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
const pushState = useSession((s) => s.pushState);
|
||||
const session = useSession((s) => s.session);
|
||||
const accountId = useSession((s) => s.accountId);
|
||||
const setAccount = useSession((s) => s.setAccount);
|
||||
const logout = useSession((s) => s.logout);
|
||||
const acctMenu = useMenu();
|
||||
const section = location.split("/")[1] || "mail";
|
||||
@@ -55,8 +52,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [openCompose, navigate]);
|
||||
|
||||
const accounts = session ? Object.entries(session.accounts) : [];
|
||||
const mailAccounts = accounts.filter(([, a]) => CAP.mail in (a.accountCapabilities ?? {}));
|
||||
/*
|
||||
* There is no account switcher any more.
|
||||
*
|
||||
* It existed to reach what other people shared, and was the wrong door: it
|
||||
* moved the whole app to somebody else's account, and Stalwart advertises
|
||||
* every capability on a shared account, so mail, calendar and contacts went
|
||||
* with it and were refused. Shares are listed where they belong now -- in
|
||||
* Files and in Contacts, beside the reader's own -- and found without anyone
|
||||
* having to know an account switch was involved.
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -67,7 +72,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<Link href="/mail" className="brand">
|
||||
<img src="/img/logo.png" alt="" />
|
||||
<span className="brand-name">
|
||||
ihasmail{mailAccounts.length > 1 ? "" : ""}
|
||||
ihasmail
|
||||
</span>
|
||||
</Link>
|
||||
<SearchBar />
|
||||
@@ -95,15 +100,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<div className="hint truncate">{session?.ihasmail?.loginName}</div>
|
||||
</div>
|
||||
</div>
|
||||
{mailAccounts.length > 1 && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuTitle>Accounts</MenuTitle>
|
||||
{mailAccounts.map(([id, a]) => (
|
||||
<MenuItem key={id} checked={id === accountId} label={a.name} onClick={() => setAccount(id)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
|
||||
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Users } from "lucide-react";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||
@@ -63,6 +63,27 @@ 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>
|
||||
</div>
|
||||
))}
|
||||
{/* Calendars other people shared. Separate from the reader's own, the way
|
||||
Files and Contacts separate theirs: you cannot edit these, and which
|
||||
of them you can see at all is somebody else's decision. Hiding one is
|
||||
remembered under an account-qualified key, since a calendar id means
|
||||
nothing outside the account holding it. */}
|
||||
{cal.sharedCalendars.length > 0 && (
|
||||
<>
|
||||
<div className="nav-section"><span>Shared with me</span></div>
|
||||
{cal.sharedCalendars.map(({ accountId, accountName, calendar: c }) => {
|
||||
const key = `${accountId}:${c.id}`;
|
||||
return (
|
||||
<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-name">{c.name}</span>
|
||||
<Users size={12} className="faint" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
|
||||
{menuCal && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user