- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings section, mail templates (Compose > More > Templates), a Subscriptions folder with bulk unsubscribe/delete, and tracker blocking with a Privacy toggle (on by default) - Own dialogs instead of browser popups; emoji picker in compose; compose windows survive a visit to Settings - Sentence completion learned from the user's sent mail (no AI) - Interface in English and Polish (one file per language, English as the fallback), picked from the browser on first run, then kept - Email translation on the server via LibreTranslate: Translate button and "Translate to...", messages in several languages translated part by part, names/addresses/emoji/file names/phone numbers kept, an animated line while translating - Antispam "Mark as spam" loads with the message, not when the menu opens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
5.1 KiB
TypeScript
144 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|