Files
ihasmail-inbuxa/web/src/views/calendar/CalendarSidebar.tsx
T
jcoffey-dev 270fb3d32c 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.
2026-08-27 10:57:27 -07:00

104 lines
6.5 KiB
TypeScript

import { useMemo, useState } from "react";
import { useLocation } from "wouter";
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";
import { formatMonthYear } from "@/lib/format";
import { formatWeekday } from "@/lib/datetime";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import type { Calendar } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog";
export function CalendarSidebar() {
const [location, navigate] = useLocation();
const cal = useCalendar();
const weekStart = useSettings((s) => s.settings.weekStart);
const locale = useSettings((s) => dateTimeKey(s.settings));
const parts = location.split("/");
const view = parts[2] || "week";
const dateStr = parts[3];
const selected = useMemo(() => (dateStr ? new Date(`${dateStr}T00:00:00`) : new Date()), [dateStr]);
const [anchor, setAnchor] = useState(() => startOfDay(selected));
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const menu = useMenu();
const [menuCal, setMenuCal] = useState<Calendar | null>(null);
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
const [share, setShare] = useState<Calendar | null>(null);
const instances = cal.instancesIn(grid[0]!, new Date(grid[41]!.getTime() + 86400000));
const dow = useMemo(() => grid.slice(0, 7).map((d) => formatWeekday(d, "narrow")), [grid, locale]);
if (!cal.available) return null;
const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
return (
<div style={{ padding: "4px 8px" }}>
<div className="mini-cal">
<div className="mc-head">
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label="Previous month"><ChevronLeft size={16} /></button>
<span>{formatMonthYear(anchor)}</span>
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label="Next month"><ChevronRight size={16} /></button>
</div>
<div className="mc-grid">
{dow.map((d, i) => <div key={i} className="mc-dow">{d}</div>)}
{grid.map((d) => (
<div key={d.toISOString()} className={`mc-day ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""} ${isSameDay(d, selected) ? "selected" : ""} ${instances.some((i) => i.start < new Date(d.getTime() + 86400000) && i.end > d) ? "has-events" : ""}`} onClick={() => navigate(`/calendar/${view === "month" ? "day" : view}/${toLocalDateOnly(d)}`)}>
{d.getDate()}
</div>
))}
</div>
</div>
<div className="nav-section" style={{ paddingLeft: 4 }}>
<span>My calendars</span>
<button className="icon-btn" title="New calendar" onClick={() => setEditCal({})}><Plus size={16} /></button>
</div>
{calendars.map((c) => (
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
<span className="cal-name">{c.name}</span>
{c.isDefault && <Star size={12} className="faint" />}
<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 && (
<>
<MenuItem icon={cal.hidden[menuCal.id] ? <Eye size={16} /> : <EyeOff size={16} />} label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} />
<MenuItem icon={<Pencil size={16} />} label="Edit" onClick={() => setEditCal(menuCal)} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
<MenuItem icon={<Star size={16} />} label="Make default" disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
</>
)}
</Popover>
{editCal && <CalendarDialog calendar={editCal} onClose={() => setEditCal(null)} />}
{share && <ShareDialog kind="Calendar" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
</div>
);
}