import { useMemo, useRef, useState, useEffect } from "react"; import { useLocation } from "wouter"; import { ChevronLeft, ChevronRight, Download, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X, AlertTriangle } 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 { BIRTHDAY_CALENDAR_ID } from "@/lib/birthdays"; import { subscriptionCalendarId } from "@/store/calendar"; import { useContacts } from "@/store/contacts"; 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, Id } from "@/jmap/types"; import { CalendarDialog } from "./CalendarDialog"; import { ShareDialog } from "../settings/ShareDialog"; import { plural, t } from "@/lib/i18n"; 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(); /* * The file picker for "Import iCAL file". A MenuItem is a button, so it * cannot wrap a hidden input the way the address-book import does; the input * lives at the end of the sidebar and the menu item reaches it through this. * * The calendar is remembered separately because opening the picker closes the * menu, and `menuCal` goes with it -- by the time a file comes back there * would be nothing left saying which calendar it was chosen for. */ const fileRef = useRef(null); const importInto = useRef(null); /* * Handing the file over, which the browser only does from a click. The * revoke below is what keeps a calendar's worth of text from sitting in * memory after the download has started. */ const exportFile = async (c: Calendar) => { try { const { text, count } = await cal.exportIcs(c.id); const url = URL.createObjectURL(new Blob([text], { type: "text/calendar" })); const a = document.createElement("a"); a.href = url; a.download = `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`; a.click(); URL.revokeObjectURL(url); toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" })); } catch (err) { toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message })); } }; const importFile = async (file: File) => { const calendarId = importInto.current; if (!calendarId) return; try { const { created, updated } = await cal.importIcs(await file.text(), calendarId); /* * The two counts are kept apart on purpose, the way the contacts import * keeps them. "Imported 40 events" over a file of 240 reads as a failure * when the other 200 were updated, and a re-import of a corrected export * -- the reason for doing this at all -- creates nothing and would * otherwise report importing nothing at all. */ const imported = plural(created, { one: "Imported {n} event", other: "Imported {n} events" }); const refreshed = plural(updated, { one: "{n} updated", other: "{n} updated" }); if (!created) toast.success(plural(updated, { one: "Updated {n} event, nothing new", other: "Updated {n} events, nothing new" })); else if (updated) toast.success(`${imported} · ${refreshed}`); else toast.success(imported); } catch (err) { toast.error(t("Could not import this file: {error}", { error: (err as Error).message })); } }; /* Added if the server says so or the reader's settings do; Stalwart will not always take the flag, so the settings carry it where it refuses. */ const addedShares = new Set(useSettings((s) => s.settings).addedShares); const isAdded = (c: { accountId: string; calendar: { id: string; isSubscribed?: boolean } }) => Boolean(c.calendar.isSubscribed) || addedShares.has(`${c.accountId}:${c.calendar.id}`); const sharedSubscribed = cal.sharedCalendars.filter(isAdded); const sharedAvailable = cal.sharedCalendars.filter((c) => !isAdded(c)); const [menuCal, setMenuCal] = useState(null); const [editCal, setEditCal] = useState | null>(null); const [share, setShare] = useState(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 birthdaysOn = useSettings((st) => st.settings.birthdayCalendar); const subscriptions = useSettings((st) => st.settings.icalSubscriptions); /* * Refreshed when the calendar is opened, and not on a timer. ihasmail has * nowhere to run a schedule -- no worker, no server-side state -- so the * honest guarantee is that a subscription is as current as the last time * somebody looked, which is also when it matters. */ useEffect(() => { if (subscriptions.length) void useCalendar.getState().refreshSubscriptions(); }, [subscriptions]); /* * The cards have to be loaded for there to be any birthdays to derive, and * the calendar is a view somebody can land on directly without ever opening * Contacts. */ useEffect(() => { if (birthdaysOn && !useContacts.getState().loaded && !useContacts.getState().loading) void useContacts.getState().loadAll(); }, [birthdaysOn]); const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); return (
{formatMonthYear(anchor)}
{dow.map((d, i) =>
{d}
)} {grid.map((d) => (
i.start < new Date(d.getTime() + 86400000) && i.end > d) ? "has-events" : ""}`} onClick={() => navigate(`/calendar/${view === "month" ? "day" : view}/${toLocalDateOnly(d)}`)}> {d.getDate()}
))}
{t("My calendars")}
{/* Derived, so no context menu and nothing to share or make default -- it is a switch, and Settings is where it is turned off entirely. */} {birthdaysOn && (
cal.toggleHidden(BIRTHDAY_CALENDAR_ID)} title={t("From the birthdays on your contacts. Nothing is stored.")} > {t("Birthdays")}
)} {subscriptions.map((sub) => { const id = subscriptionCalendarId(sub.id); const failed = cal.subscriptionErrors[sub.id]; const count = cal.subscriptionEvents[sub.id]?.length ?? 0; return (
cal.toggleHidden(id)} title={failed ? t("Could not read this calendar: {reason}", { reason: failed }) : t("Subscribed to {url}", { url: sub.url })} > {sub.name} {/* A subscription that cannot be read says so here rather than drawing an empty calendar, which looks like a calendar with nothing in it. */} {failed ? : count === 0 ? null : null}
); })} {calendars.map((c) => (
cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}> {c.name} {Object.keys(c.shareWith ?? {}).length > 0 && } {c.isDefault && }
))} {/* Calendars other people shared, split by whether the reader has added them. Stalwart returns every calendar in a reachable account with full rights, so "shared with me" and "there is an account here at all" look identical -- `isSubscribed` is the only thing that tells them apart, and adding one is a deliberate act rather than a guess on our part. */} {sharedSubscribed.length > 0 && ( <>
{t("Shared with me")}
{sharedSubscribed.map(({ accountId, accountName, calendar: c }) => { const key = `${accountId}:${c.id}`; return (
cal.toggleHidden(key)} title={t("{name} — shared by {owner}", { name: c.name, owner: accountName })}> {c.name}
); })} )} {sharedAvailable.length > 0 && ( <>
{t("Available to add")}
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
{c.name}
))} )} {menuCal && ( <> : } label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} /> } label={t("Edit")} onClick={() => setEditCal(menuCal)} /> } label={t("Import iCAL file…")} disabled={!menuCal.myRights.mayWriteAll && !menuCal.myRights.mayWriteOwn} onClick={() => { importInto.current = menuCal.id; fileRef.current?.click(); }} /> {/* No rights test: exporting is reading, and a calendar you cannot read is not in this list to begin with. */} } label={t("Export iCAL file")} onClick={() => void exportFile(menuCal)} /> } label={t("Share…")} onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} /> {/* Revoking every share at once, without walking the dialog and removing people one at a time. Only offered when there is something to revoke. */} {Object.keys(menuCal.shareWith ?? {}).length > 0 && ( } label={t("Stop sharing")} disabled={!menuCal.myRights.mayShare} onClick={async () => { const who = Object.keys(menuCal.shareWith ?? {}).length; if (!(await confirmDialog({ title: t("Stop sharing “{name}”?", { name: menuCal.name }), message: plural(who, { one: "{n} person will lose access. Events in it are not affected.", other: "{n} people will lose access. Events in it are not affected." }), confirmLabel: t("Stop sharing"), danger: true, }))) return; try { await cal.updateCalendar(menuCal.id, { shareWith: null }); toast.success(t("No longer shared")); } catch (err) { toast.error((err as Error).message); } }} /> )} } label={t("Make default")} disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial).catch((err) => toast.error((err as Error).message))} /> } label={t("Delete")} disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: menuCal.name }), message: t("All events in this calendar will be deleted."), confirmLabel: t("Delete"), danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} /> )} {/* Cleared after every pick, so choosing the same file twice still counts as a change and fires again. */} { const file = e.target.files?.[0]; e.target.value = ""; if (file) void importFile(file); }} /> {editCal && setEditCal(null)} />} {share && setShare(null)} />}
); }