Files
webmail-client/frontend/src/components/mail/SnoozeMenu.tsx
T

144 lines
5.1 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import type { MessageKey } from "../../i18n/en";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
const MORNING = 8; // the hour "tomorrow", "this weekend" and "next week" start at
function at(day: Date, hour: number): Date {
const d = new Date(day);
d.setHours(hour, 0, 0, 0);
return d;
}
function addDays(day: Date, n: number): Date {
const d = new Date(day);
d.setDate(d.getDate() + n);
return d;
}
/** The quick choices, in the order shown. Ones already past are left out. */
export function snoozePresets(now: Date): { label: MessageKey; when: Date }[] {
const out: { label: MessageKey; when: Date }[] = [];
// Three hours on, rounded up to the hour, while that is still this evening.
const later = new Date(now);
later.setHours(later.getHours() + 3, 0, 0, 0);
if (later.getDate() === now.getDate() && later.getHours() <= 21) {
out.push({ label: "snooze.laterToday", when: later });
}
out.push({ label: "snooze.tomorrow", when: at(addDays(now, 1), MORNING) });
const day = now.getDay(); // 0 = Sunday
if (day >= 1 && day <= 4) {
out.push({ label: "snooze.weekend", when: at(addDays(now, 6 - day), MORNING) });
}
// The coming Monday — or, on a Sunday, the one after (tomorrow is offered already).
const toMonday = ((8 - day) % 7) || 7;
out.push({ label: "snooze.nextWeek", when: at(addDays(now, toMonday === 1 ? 8 : toMonday), MORNING) });
return out;
}
function whenLabel(d: Date, now: Date): string {
const sameDay = d.toDateString() === now.toDateString();
const day = sameDay
? ""
: d.toLocaleDateString(getDateLocale(), { weekday: "short", day: "numeric", month: "short" }) + ", ";
return day + formatClock(d);
}
// "YYYY-MM-DDTHH:MM" in local time, for <input type="datetime-local">.
function localInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Popover: when should this conversation come back? */
export function SnoozeMenu({
onPick,
onClose,
align = "left",
}: {
onPick: (when: Date) => void;
onClose: () => void;
align?: "left" | "right";
}) {
const t = useT();
const ref = useRef<HTMLDivElement>(null);
const now = new Date();
const [custom, setCustom] = useState(false);
const [value, setValue] = useState(() => localInput(at(addDays(now, 1), MORNING)));
const picked = new Date(value);
const valid = !Number.isNaN(picked.getTime()) && picked.getTime() > Date.now();
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
return (
<div
ref={ref}
role="menu"
className={`absolute top-full z-40 mt-1 w-72 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl ${
align === "right" ? "right-0" : "left-0"
}`}
>
<div className="px-3 pb-1 pt-1.5 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("snooze.until")}
</div>
{snoozePresets(now).map((p) => (
<button
key={p.label}
role="menuitem"
className="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => onPick(p.when)}
>
<span>{t(p.label)}</span>
<span className="text-[13px] text-app-muted">{whenLabel(p.when, now)}</span>
</button>
))}
<div className="my-1 border-t border-app-border" />
{custom ? (
<div className="space-y-2 px-3 py-1.5">
<input
type="datetime-local"
className="w-full rounded border border-app-border bg-app-surface px-2 py-1 text-app-fg"
value={value}
min={localInput(now)}
onChange={(e) => setValue(e.target.value)}
autoFocus
/>
<div className="flex justify-end gap-2">
<button className="rounded px-2.5 py-1 text-app-muted hover:bg-app-hover" onClick={() => setCustom(false)}>
{t("common.cancel")}
</button>
<button
className="rounded bg-app-accent px-2.5 py-1 font-medium text-app-accent-fg disabled:opacity-50"
disabled={!valid}
onClick={() => onPick(picked)}
>
{t("snooze.action")}
</button>
</div>
</div>
) : (
<button
role="menuitem"
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => setCustom(true)}
>
{t("snooze.pick")}
</button>
)}
</div>
);
}