mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
415 lines
18 KiB
TypeScript
415 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { addDays, endOfDay, format, isSameDay, startOfDay } from "date-fns";
|
|
import {
|
|
CalendarEvent as CalendarIcon,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ExternalLink,
|
|
ListTodo,
|
|
Plus,
|
|
Search,
|
|
Users,
|
|
X,
|
|
} from "@/components/icons";
|
|
import { Link } from "@/i18n/navigation";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { useCalendarStore } from "@/stores/calendar-store";
|
|
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
|
import { usePolicyStore } from "@/stores/policy-store";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { useTaskStore } from "@/stores/task-store";
|
|
import { getTimedEventBoundsForDay } from "@/lib/calendar-utils";
|
|
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
|
import { formatDateTime, cn } from "@/lib/utils";
|
|
import { Avatar } from "@/components/ui/avatar";
|
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
|
|
|
export type MvSidePanelId = "calendar" | "tasks" | "contacts";
|
|
|
|
const HOUR_PX = 48;
|
|
|
|
/**
|
|
* Mountain View's side panel: the strip of app icons down the right edge
|
|
* that Gmail has, each opening a narrow panel next to the mail - today's
|
|
* calendar, the task list, the address book - without leaving the inbox.
|
|
*/
|
|
export function MvSidePanel() {
|
|
const t = useTranslations("sidebar");
|
|
const client = useAuthStore((s) => s.client);
|
|
const supportsCalendar = useCalendarStore((s) => s.supportsCalendar);
|
|
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled"));
|
|
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
|
|
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
|
const [open, setOpen] = useState<MvSidePanelId | null>(null);
|
|
|
|
const showCalendar = supportsCalendar && calendarEnabled;
|
|
const showContacts = (client?.supportsContacts() ?? false) && contactsEnabled;
|
|
const showTasks = showCalendar && tasksEnabled;
|
|
|
|
const buttons: Array<{ id: MvSidePanelId; label: string; icon: typeof CalendarIcon; color: string }> = [
|
|
...(showCalendar ? [{ id: "calendar" as const, label: t("calendar"), icon: CalendarIcon, color: "text-[#1a73e8]" }] : []),
|
|
...(showTasks ? [{ id: "tasks" as const, label: t("mv_tasks"), icon: ListTodo, color: "text-[#1a73e8]" }] : []),
|
|
...(showContacts ? [{ id: "contacts" as const, label: t("contacts"), icon: Users, color: "text-[#1a73e8]" }] : []),
|
|
];
|
|
|
|
if (buttons.length === 0) return null;
|
|
// A panel whose app went away (policy change, account switch) stays shut.
|
|
const active = open && buttons.some((b) => b.id === open) ? open : null;
|
|
|
|
return (
|
|
<>
|
|
{active && (
|
|
<aside
|
|
data-mv-side-panel={active}
|
|
className="mb-4 me-2 flex w-[300px] shrink-0 flex-col overflow-hidden rounded-2xl bg-background"
|
|
aria-label={buttons.find((b) => b.id === active)?.label}
|
|
>
|
|
{active === "calendar" && <CalendarPanel onClose={() => setOpen(null)} />}
|
|
{active === "tasks" && <TasksPanel onClose={() => setOpen(null)} />}
|
|
{active === "contacts" && <ContactsPanel onClose={() => setOpen(null)} />}
|
|
</aside>
|
|
)}
|
|
<nav
|
|
data-mv-side-rail=""
|
|
className="flex w-14 shrink-0 flex-col items-center gap-2 pt-3"
|
|
aria-label={t("mv_side_panel")}
|
|
>
|
|
{buttons.map(({ id, label, icon: Icon, color }) => (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
onClick={() => setOpen((cur) => (cur === id ? null : id))}
|
|
className={cn(
|
|
"relative grid h-10 w-10 place-items-center rounded-full transition-colors hover:bg-[var(--mv-nav-hover)]",
|
|
active === id && "bg-[var(--mv-nav-selected)]",
|
|
)}
|
|
title={label}
|
|
aria-label={label}
|
|
aria-pressed={active === id}
|
|
>
|
|
<Icon className={cn("h-5 w-5", color)} />
|
|
</button>
|
|
))}
|
|
<div className="my-1 h-px w-5 bg-border" aria-hidden />
|
|
</nav>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function PanelHeader({ title, subtitle, href, onClose, children }: {
|
|
title: string;
|
|
subtitle?: React.ReactNode;
|
|
href: string;
|
|
onClose: () => void;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const t = useTranslations("sidebar");
|
|
const tCommon = useTranslations("common");
|
|
return (
|
|
<div className="flex items-start gap-1 px-4 pt-4 pb-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className={cn(subtitle ? "text-[11px] font-medium uppercase tracking-wider text-muted-foreground" : "text-[22px] leading-7")}>{title}</div>
|
|
{subtitle && <div className="text-sm font-medium text-primary">{subtitle}</div>}
|
|
</div>
|
|
{children}
|
|
<Link
|
|
href={href}
|
|
className="grid h-9 w-9 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
title={t("mv_open_full")}
|
|
aria-label={t("mv_open_full")}
|
|
>
|
|
<ExternalLink className="h-[18px] w-[18px]" />
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="grid h-9 w-9 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
title={tCommon("close")}
|
|
aria-label={tCommon("close")}
|
|
>
|
|
<X className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A day of the calendar on an hour grid, with a line at the current time. */
|
|
function CalendarPanel({ onClose }: { onClose: () => void }) {
|
|
const t = useTranslations("sidebar");
|
|
const tCal = useTranslations("calendar");
|
|
const client = useAuthStore((s) => s.client);
|
|
const calendars = useCalendarStore((s) => s.calendars);
|
|
const events = useCalendarStore((s) => s.events);
|
|
const selectedCalendarIds = useCalendarStore((s) => s.selectedCalendarIds);
|
|
const fetchCalendars = useCalendarStore((s) => s.fetchCalendars);
|
|
const fetchEvents = useCalendarStore((s) => s.fetchEvents);
|
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
|
const [day, setDay] = useState(() => startOfDay(new Date()));
|
|
const [now, setNow] = useState(() => new Date());
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (client && calendars.length === 0) void fetchCalendars(client);
|
|
}, [client, calendars.length, fetchCalendars]);
|
|
|
|
useEffect(() => {
|
|
if (!client || calendars.length === 0) return;
|
|
void fetchEvents(client, format(day, "yyyy-MM-dd'T'00:00:00"), format(endOfDay(day), "yyyy-MM-dd'T'23:59:59"), { silent: true });
|
|
}, [client, calendars.length, day, fetchEvents]);
|
|
|
|
useEffect(() => {
|
|
const id = window.setInterval(() => setNow(new Date()), 60_000);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
// Open on the working part of the day: an hour before now, or 8:00.
|
|
useEffect(() => {
|
|
const hour = isSameDay(day, new Date()) ? Math.max(0, new Date().getHours() - 1) : 8;
|
|
if (scrollRef.current) scrollRef.current.scrollTop = hour * HOUR_PX;
|
|
}, [day]);
|
|
|
|
const colorOf = (event: CalendarEvent) => {
|
|
const calId = Object.keys(event.calendarIds ?? {})[0];
|
|
return event.color || calendars.find((c) => c.id === calId)?.color || "var(--color-primary)";
|
|
};
|
|
|
|
const visible = events.filter((e) => {
|
|
const ids = Object.keys(e.calendarIds ?? {});
|
|
return selectedCalendarIds.length === 0 || ids.some((id) => selectedCalendarIds.includes(id));
|
|
});
|
|
const timed = visible
|
|
.map((e) => ({ e, b: getTimedEventBoundsForDay(e, day) }))
|
|
.filter((x): x is { e: CalendarEvent; b: NonNullable<typeof x.b> } => x.b !== null);
|
|
const allDay = visible.filter((e) => {
|
|
if (!e.showWithoutTime) return false;
|
|
const start = startOfDay(new Date(e.start));
|
|
return start <= day && day < addDays(start, Math.max(1, Math.round(durationDays(e.duration))));
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<PanelHeader title={t("calendar")} subtitle={formatDateTime(day, timeFormat, { weekday: "short", month: "short", day: "numeric", dateOnly: true })} href="/calendar" onClose={onClose} />
|
|
<div className="flex items-center gap-1 px-3 pb-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setDay(startOfDay(new Date()))}
|
|
className="h-9 rounded-full border border-border px-4 text-sm font-medium hover:bg-muted"
|
|
>
|
|
{tCal("views.today")}
|
|
</button>
|
|
<button type="button" onClick={() => setDay((d) => addDays(d, -1))} className="grid h-9 w-9 place-items-center rounded-full hover:bg-muted" aria-label={t("mv_previous_day")} title={t("mv_previous_day")}>
|
|
<ChevronLeft className="h-5 w-5" />
|
|
</button>
|
|
<button type="button" onClick={() => setDay((d) => addDays(d, 1))} className="grid h-9 w-9 place-items-center rounded-full hover:bg-muted" aria-label={t("mv_next_day")} title={t("mv_next_day")}>
|
|
<ChevronRight className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
{allDay.length > 0 && (
|
|
<div className="space-y-1 border-b border-border px-3 pb-2">
|
|
{allDay.map((e) => (
|
|
<div key={e.id} className="truncate rounded px-2 py-0.5 text-xs font-medium text-white" style={{ backgroundColor: colorOf(e) }}>
|
|
{e.title || tCal("tasks.no_title")}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div ref={scrollRef} className="relative min-h-0 flex-1 overflow-y-auto" data-mv-day-grid="">
|
|
<div className="relative" style={{ height: 24 * HOUR_PX }}>
|
|
{Array.from({ length: 24 }, (_, h) => (
|
|
<div key={h} className="absolute inset-x-0 flex" style={{ top: h * HOUR_PX }}>
|
|
<span className="w-14 shrink-0 -translate-y-2 pe-2 text-end text-[10px] text-muted-foreground">
|
|
{h === 0 ? "" : new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: "numeric", hour12: timeFormat === "12h" })}
|
|
</span>
|
|
<span className="flex-1 border-t border-border" />
|
|
</div>
|
|
))}
|
|
{timed.map(({ e, b }) => (
|
|
<div
|
|
key={e.id}
|
|
data-mv-panel-event=""
|
|
className="absolute end-2 overflow-hidden rounded px-1.5 py-0.5 text-xs leading-tight text-white"
|
|
style={{
|
|
top: (b.startMinutes / 60) * HOUR_PX,
|
|
height: Math.max(18, ((b.endMinutes - b.startMinutes) / 60) * HOUR_PX - 2),
|
|
insetInlineStart: 56,
|
|
backgroundColor: colorOf(e),
|
|
}}
|
|
title={e.title}
|
|
>
|
|
<div className="truncate font-medium">{e.title || tCal("tasks.no_title")}</div>
|
|
</div>
|
|
))}
|
|
{isSameDay(day, now) && (
|
|
<div
|
|
className="pointer-events-none absolute end-0 h-0.5 bg-red-500"
|
|
style={{ top: ((now.getHours() * 60 + now.getMinutes()) / 60) * HOUR_PX, insetInlineStart: 56 }}
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
</div>
|
|
{timed.length === 0 && allDay.length === 0 && (
|
|
<p className="sr-only">{tCal("events.no_events")}</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Whole days in an ISO-8601 duration such as "P1D" or "P2DT0H". */
|
|
function durationDays(duration: string | undefined): number {
|
|
if (!duration) return 1;
|
|
const m = /P(?:(\d+)W)?(?:(\d+)D)?/.exec(duration);
|
|
if (!m) return 1;
|
|
return (Number(m[1] ?? 0) * 7) + Number(m[2] ?? 0) || 1;
|
|
}
|
|
|
|
/** The task list: what is still open, a box to tick it off, and a line to add one. */
|
|
function TasksPanel({ onClose }: { onClose: () => void }) {
|
|
const t = useTranslations("sidebar");
|
|
const tCal = useTranslations("calendar");
|
|
const client = useAuthStore((s) => s.client);
|
|
const calendars = useCalendarStore((s) => s.calendars);
|
|
const fetchCalendars = useCalendarStore((s) => s.fetchCalendars);
|
|
const tasks = useTaskStore((s) => s.tasks);
|
|
const fetchTasks = useTaskStore((s) => s.fetchTasks);
|
|
const createTask = useTaskStore((s) => s.createTask);
|
|
const toggleTaskComplete = useTaskStore((s) => s.toggleTaskComplete);
|
|
const [draft, setDraft] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!client) return;
|
|
if (calendars.length === 0) void fetchCalendars(client);
|
|
void fetchTasks(client);
|
|
}, [client, calendars.length, fetchCalendars, fetchTasks]);
|
|
|
|
const pending = tasks.filter((task) => task.progress !== "completed" && task.progress !== "cancelled");
|
|
const taskCalendar = calendars.find((c) => c.isTasksOnly) ?? calendars[0];
|
|
|
|
return (
|
|
<>
|
|
<PanelHeader title={t("mv_tasks")} href="/calendar" onClose={onClose} />
|
|
{client && taskCalendar && (
|
|
<form
|
|
className="px-4 pb-2"
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
const title = draft.trim();
|
|
if (!title) return;
|
|
setDraft("");
|
|
// null: a plugin stopped it; give the user their text back.
|
|
const created = await createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [taskCalendar.id]: true } });
|
|
if (!created) setDraft(title);
|
|
}}
|
|
>
|
|
<label className="flex items-center gap-3 rounded-full px-1 py-1.5 text-sm text-primary hover:bg-muted/60">
|
|
<Plus className="h-5 w-5 shrink-0" />
|
|
<input
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
placeholder={tCal("tasks.create")}
|
|
className="min-w-0 flex-1 bg-transparent text-foreground placeholder:text-primary focus:outline-none"
|
|
/>
|
|
</label>
|
|
</form>
|
|
)}
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
|
{pending.length === 0 && <p className="px-3 py-6 text-center text-sm text-muted-foreground">{tCal("tasks.no_tasks")}</p>}
|
|
{pending.map((task) => (
|
|
<div key={task.id} className="flex items-start gap-3 rounded-lg px-2 py-2 hover:bg-muted/50" data-mv-panel-task="">
|
|
<button
|
|
type="button"
|
|
onClick={() => client && void toggleTaskComplete(client, task)}
|
|
className="mt-0.5 h-5 w-5 shrink-0 rounded-full border-2 border-muted-foreground/60 hover:border-primary"
|
|
aria-label={tCal("tasks.mark_complete")}
|
|
title={tCal("tasks.mark_complete")}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm">{task.title || tCal("tasks.no_title")}</div>
|
|
{task.due && <div className="text-xs text-muted-foreground">{format(new Date(task.due), "PP")}</div>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** The address book, searchable; a contact opens a new message to them. */
|
|
function ContactsPanel({ onClose }: { onClose: () => void }) {
|
|
const t = useTranslations("sidebar");
|
|
const tContacts = useTranslations("contacts");
|
|
const client = useAuthStore((s) => s.client);
|
|
const contacts = useContactStore((s) => s.contacts);
|
|
const fetchContacts = useContactStore((s) => s.fetchContacts);
|
|
const [query, setQuery] = useState("");
|
|
const [searching, setSearching] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (client && contacts.length === 0) void fetchContacts(client);
|
|
}, [client, contacts.length, fetchContacts]);
|
|
|
|
const rows = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
return contacts
|
|
.filter((c) => c.kind !== "group")
|
|
.map((c) => ({ c, name: getContactDisplayName(c), email: getContactPrimaryEmail(c) }))
|
|
.filter((r) => r.name || r.email)
|
|
.filter((r) => !q || r.name.toLowerCase().includes(q) || r.email.toLowerCase().includes(q))
|
|
.sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email));
|
|
}, [contacts, query]);
|
|
|
|
return (
|
|
<>
|
|
<PanelHeader title={t("contacts")} href="/contacts" onClose={onClose}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearching((v) => !v)}
|
|
className="grid h-9 w-9 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
aria-label={tContacts("search_placeholder")}
|
|
title={tContacts("search_placeholder")}
|
|
>
|
|
<Search className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</PanelHeader>
|
|
{searching && (
|
|
<div className="px-4 pb-2">
|
|
<input
|
|
autoFocus
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={tContacts("search_placeholder")}
|
|
className="h-9 w-full rounded-full bg-[var(--mv-search-bg)] px-4 text-sm focus:outline-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
<Link href="/contacts" className="mx-4 mb-2 flex items-center gap-3 rounded-full py-1.5 text-sm font-medium text-primary hover:bg-muted/60">
|
|
<Plus className="h-5 w-5" />
|
|
{tContacts("create_new")}
|
|
</Link>
|
|
<div className="min-h-0 flex-1 overflow-y-auto pb-3">
|
|
{rows.map(({ c, name, email }) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
disabled={!email}
|
|
onClick={() => email && requestInternalMailto(`mailto:${email}`)}
|
|
className="flex w-full items-center gap-3 px-4 py-2 text-start hover:bg-muted/50 disabled:cursor-default"
|
|
title={email || undefined}
|
|
data-mv-panel-contact=""
|
|
>
|
|
<Avatar name={name} email={email} size="md" className="h-9 w-9 shrink-0" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm">{name || email}</span>
|
|
{name && email && <span className="block truncate text-xs text-muted-foreground">{email}</span>}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|