ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, PenSquare, Settings, Users, LogOut, Plus, RefreshCw } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { MailboxTree } from "./mail/MailboxTree";
|
||||
import { CalendarSidebar } from "./calendar/CalendarSidebar";
|
||||
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { CAP } from "@/jmap/client";
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const [location, navigate] = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
||||
const update = useSettings((s) => s.update);
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
const pushConnected = useSession((s) => s.pushConnected);
|
||||
const session = useSession((s) => s.session);
|
||||
const accountId = useSession((s) => s.accountId);
|
||||
const setAccount = useSession((s) => s.setAccount);
|
||||
const logout = useSession((s) => s.logout);
|
||||
const acctMenu = useMenu();
|
||||
const section = location.split("/")[1] || "mail";
|
||||
|
||||
useGlobalShortcuts({ onHelp: () => setHelpOpen(true) });
|
||||
useEffect(() => setDrawer(false), [location]);
|
||||
|
||||
// Deep link: /mail?compose=new (PWA shortcut) / mailto handler
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("compose") === "new") {
|
||||
openCompose();
|
||||
navigate("/mail", { replace: true });
|
||||
}
|
||||
const mailto = params.get("mailto");
|
||||
if (mailto) {
|
||||
const [addr, qs] = mailto.replace(/^mailto:/, "").split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
openCompose({ to: addr ? addr.split(",").map((e) => ({ name: null, email: e.trim() })) : [], subject: q.get("subject") ?? "", html: q.get("body") ? `<div>${q.get("body")}</div>` : "" });
|
||||
navigate("/mail", { replace: true });
|
||||
}
|
||||
}, [openCompose, navigate]);
|
||||
|
||||
const accounts = session ? Object.entries(session.accounts) : [];
|
||||
const mailAccounts = accounts.filter(([, a]) => CAP.mail in (a.accountCapabilities ?? {}));
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<button className="icon-btn" aria-label="Menu" onClick={() => (isMobile ? setDrawer(true) : update({ sidebarCollapsed: !collapsed }))}>
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
<Link href="/mail" className="brand">
|
||||
<img src="/img/logo.png" alt="" />
|
||||
<span className="brand-name">
|
||||
ihasmail{mailAccounts.length > 1 ? "" : ""}
|
||||
</span>
|
||||
</Link>
|
||||
<SearchBar />
|
||||
<div className="topbar-actions">
|
||||
<span className="push-dot hide-mobile" title={pushConnected ? "Live updates connected" : "Live updates disconnected (polling)"} aria-hidden="true">
|
||||
<span className={`push-dot ${pushConnected ? "on" : ""}`} />
|
||||
</span>
|
||||
<button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}>
|
||||
<HelpCircle size={21} />
|
||||
</button>
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label="Settings" title="Settings">
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label="Account">
|
||||
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
||||
</button>
|
||||
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
|
||||
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
|
||||
<Avatar who={{ name: session?.username, email: session?.username }} />
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 600 }} className="truncate">
|
||||
{session?.username}
|
||||
</div>
|
||||
<div className="hint truncate">{session?.ihasmail?.loginName}</div>
|
||||
</div>
|
||||
</div>
|
||||
{mailAccounts.length > 1 && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuTitle>Accounts</MenuTitle>
|
||||
{mailAccounts.map(([id, a]) => (
|
||||
<MenuItem key={id} checked={id === accountId} label={a.name} onClick={() => setAccount(id)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
|
||||
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
|
||||
<MenuItem icon={<LogOut size={16} />} label="Sign out" onClick={() => void logout()} />
|
||||
</Popover>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
|
||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||
<aside className={`sidebar ${drawer ? "open" : ""}`}>
|
||||
<button
|
||||
className="compose-btn"
|
||||
onClick={() => {
|
||||
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
|
||||
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
|
||||
else openCompose();
|
||||
}}
|
||||
>
|
||||
{section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
|
||||
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : "Compose"}</span>
|
||||
</button>
|
||||
<div className="sidebar-scroll">
|
||||
{(section === "mail" || section === "search") && <MailboxTree />}
|
||||
{section === "calendar" && <CalendarSidebar />}
|
||||
{section === "contacts" && <div className="nav-section"><span>Contacts</span></div>}
|
||||
{section === "files" && <div className="nav-section"><span>Files</span></div>}
|
||||
{section === "settings" && <div className="nav-section"><span>Settings</span></div>}
|
||||
</div>
|
||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||
<nav className="module-bar" aria-label="Go to">
|
||||
<ModuleLink href="/mail" icon={<Mail size={20} />} label="Mail" active={section === "mail" || section === "search"} />
|
||||
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label="Calendar" active={section === "calendar"} />
|
||||
<ModuleLink href="/contacts" icon={<Users size={20} />} label="Contacts" active={section === "contacts"} />
|
||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label="Files" active={section === "files"} />
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="main">{children}</main>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<>
|
||||
{(section === "mail" || section === "search") && !location.split("/")[3] && (
|
||||
<button className="fab" aria-label="Compose" onClick={() => openCompose()}>
|
||||
<PenSquare size={24} />
|
||||
</button>
|
||||
)}
|
||||
<nav className="mobile-tabbar" aria-label="Sections">
|
||||
<Link href="/mail" className={section === "mail" || section === "search" ? "active" : ""}>
|
||||
<Mail size={22} />
|
||||
Mail
|
||||
</Link>
|
||||
<Link href="/calendar" className={section === "calendar" ? "active" : ""}>
|
||||
<Calendar size={22} />
|
||||
Calendar
|
||||
</Link>
|
||||
<Link href="/contacts" className={section === "contacts" ? "active" : ""}>
|
||||
<Users size={22} />
|
||||
Contacts
|
||||
</Link>
|
||||
<Link href="/files" className={section === "files" ? "active" : ""}>
|
||||
<FolderOpen size={22} />
|
||||
Files
|
||||
</Link>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
<ShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Outlook-style module switcher at the bottom of the folder pane. */
|
||||
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
|
||||
return (
|
||||
<Link href={href} className={`module-link ${active ? "active" : ""}`} title={label} aria-label={label} aria-current={active ? "page" : undefined}>
|
||||
{icon}
|
||||
<span className="module-label">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaBar() {
|
||||
const quotas = useMail((s) => s.quotas);
|
||||
const q = quotas.find((x) => x.resourceType === "octets" && x.types.includes("Email")) ?? quotas.find((x) => x.resourceType === "octets");
|
||||
if (!q || !q.hardLimit) return null;
|
||||
const pct = Math.min(100, Math.round((q.used / q.hardLimit) * 100));
|
||||
return (
|
||||
<div className="quota" title={`${formatSize(q.used)} of ${formatSize(q.hardLimit)} used`}>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>
|
||||
{formatSize(q.used)} of {formatSize(q.hardLimit)}
|
||||
</span>
|
||||
<ChevronsUpDown size={12} style={{ opacity: 0 }} />
|
||||
</div>
|
||||
<div className="quota-bar">
|
||||
<span className={pct > 95 ? "danger" : pct > 80 ? "warn" : ""} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { ApiError } from "@/jmap/client";
|
||||
|
||||
export function LoginPage() {
|
||||
const login = useSession((s) => s.login);
|
||||
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [showTotp, setShowTotp] = useState(false);
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(username.trim(), password, totp.trim(), remember);
|
||||
localStorage.setItem("ihasmail:lastUser", username.trim());
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.code === "invalid_credentials") {
|
||||
setError(showTotp ? "Invalid credentials or verification code." : "Invalid username or password.");
|
||||
if (!showTotp && password) setShowTotp(true);
|
||||
} else if (err.code === "rate_limited") setError("Too many attempts. Please wait a few minutes and try again.");
|
||||
else setError(err.message || "Could not sign in.");
|
||||
} else setError("Network error. Please check your connection.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={submit}>
|
||||
<div className="logo">
|
||||
<img src="/img/logo.png" alt="" width={120} height={113} />
|
||||
<p className="tagline">Fast, friendly webmail. Your mailbox, your way.</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="error-box mb-16" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label htmlFor="u">Email or username</label>
|
||||
<input id="u" className="input" type="text" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus={!username} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="p">Password</label>
|
||||
<div className="pw-wrap">
|
||||
<input id="p" className="input" type={showPw ? "text" : "password"} autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus={Boolean(username)} required style={{ paddingRight: 40 }} />
|
||||
<button type="button" className="icon-btn" onClick={() => setShowPw((v) => !v)} aria-label={showPw ? "Hide password" : "Show password"} tabIndex={-1}>
|
||||
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showTotp ? (
|
||||
<div className="field">
|
||||
<label htmlFor="t">Two-factor code</label>
|
||||
<input id="t" className="input" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" value={totp} onChange={(e) => setTotp(e.target.value)} autoFocus />
|
||||
<span className="hint">Enter the code from your authenticator app if your account uses 2FA.</span>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="btn btn-ghost btn-sm" style={{ marginBottom: 12, color: "var(--fg-muted)" }} onClick={() => setShowTotp(true)}>
|
||||
<ShieldCheck size={16} /> I have a two-factor code
|
||||
</button>
|
||||
)}
|
||||
<label className="check" style={{ marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
|
||||
<span>Keep me signed in on this device</span>
|
||||
</label>
|
||||
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
|
||||
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
<p className="foot">
|
||||
ihasmail by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { Search, SlidersHorizontal, X } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
|
||||
export function SearchBar() {
|
||||
const [location, navigate] = useLocation();
|
||||
const search = useSearch();
|
||||
const [q, setQ] = useState("");
|
||||
const [adv, setAdv] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const [advFields, setAdvFields] = useState({ from: "", to: "", subject: "", words: "", hasAttachment: false, unread: false, folder: "", after: "", before: "" });
|
||||
|
||||
// Sync from URL when on /search
|
||||
useEffect(() => {
|
||||
if (location.startsWith("/search")) {
|
||||
const params = new URLSearchParams(search);
|
||||
setQ(params.get("q") ?? "");
|
||||
} else setQ("");
|
||||
}, [location, search]);
|
||||
|
||||
useEffect(() => keyboard.pushScope("search", [{ keys: "/", description: "Search mail", group: "Navigation", handler: () => inputRef.current?.focus() }]), []);
|
||||
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const query = q.trim();
|
||||
if (!query) return;
|
||||
setAdv(false);
|
||||
navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const applyAdvanced = () => {
|
||||
const parts: string[] = [];
|
||||
const f = advFields;
|
||||
if (f.from) parts.push(`from:${quote(f.from)}`);
|
||||
if (f.to) parts.push(`to:${quote(f.to)}`);
|
||||
if (f.subject) parts.push(`subject:${quote(f.subject)}`);
|
||||
if (f.words) parts.push(f.words);
|
||||
if (f.hasAttachment) parts.push("has:attachment");
|
||||
if (f.unread) parts.push("is:unread");
|
||||
if (f.folder) parts.push(`in:${quote(f.folder)}`);
|
||||
if (f.after) parts.push(`after:${f.after}`);
|
||||
if (f.before) parts.push(`before:${f.before}`);
|
||||
const query = parts.join(" ");
|
||||
setQ(query);
|
||||
setAdv(false);
|
||||
if (query) navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="searchbar" role="search" onSubmit={submit}>
|
||||
<div className="search-input">
|
||||
<Search size={18} className="muted" />
|
||||
<input ref={inputRef} type="search" placeholder="Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search mail" enterKeyHint="search" />
|
||||
{q && (
|
||||
<button type="button" className="icon-btn sm" aria-label="Clear" onClick={() => { setQ(""); if (location.startsWith("/search")) navigate("/mail"); }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={`icon-btn sm ${adv ? "active" : ""}`} aria-label="Advanced search" title="Advanced search" onClick={() => setAdv((v) => !v)}>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{adv && (
|
||||
<div className="search-panel">
|
||||
<div className="grid">
|
||||
<label className="field"><span className="label">From</span><input className="input sm" value={advFields.from} onChange={(e) => setAdvFields({ ...advFields, from: e.target.value })} /></label>
|
||||
<label className="field"><span className="label">To</span><input className="input sm" value={advFields.to} onChange={(e) => setAdvFields({ ...advFields, to: e.target.value })} /></label>
|
||||
<label className="field"><span className="label">Subject</span><input className="input sm" value={advFields.subject} onChange={(e) => setAdvFields({ ...advFields, subject: e.target.value })} /></label>
|
||||
<label className="field"><span className="label">Has the words</span><input className="input sm" value={advFields.words} onChange={(e) => setAdvFields({ ...advFields, words: e.target.value })} /></label>
|
||||
<label className="field"><span className="label">Folder</span>
|
||||
<select className="select" style={{ height: 32 }} value={advFields.folder} onChange={(e) => setAdvFields({ ...advFields, folder: e.target.value })}>
|
||||
<option value="">All mail</option>
|
||||
{Object.values(mailboxes).sort((a, b) => a.name.localeCompare(b.name)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="field"><span className="label">Date</span>
|
||||
<div className="row"><input className="input sm" type="date" value={advFields.after} onChange={(e) => setAdvFields({ ...advFields, after: e.target.value })} /><span className="muted">to</span><input className="input sm" type="date" value={advFields.before} onChange={(e) => setAdvFields({ ...advFields, before: e.target.value })} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginTop: 4 }}>
|
||||
<div className="row gap-16">
|
||||
<label className="check"><input type="checkbox" checked={advFields.hasAttachment} onChange={(e) => setAdvFields({ ...advFields, hasAttachment: e.target.checked })} /> Has attachment</label>
|
||||
<label className="check"><input type="checkbox" checked={advFields.unread} onChange={(e) => setAdvFields({ ...advFields, unread: e.target.checked })} /> Unread only</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setAdv(false)}>Cancel</button>
|
||||
<button type="button" className="btn btn-primary" onClick={applyAdvanced}>Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function quote(s: string): string {
|
||||
return /\s/.test(s) ? `"${s.replace(/"/g, "")}"` : s;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { Kbd } from "@/ui/misc";
|
||||
|
||||
export function useGlobalShortcuts({ onHelp }: { onHelp: () => void }) {
|
||||
const [, navigate] = useLocation();
|
||||
useEffect(() => {
|
||||
const go = (role: string) => () => {
|
||||
const id = useMail.getState().roleId(role as never);
|
||||
if (id) navigate(`/mail/${id}`);
|
||||
};
|
||||
return keyboard.pushScope("global", [
|
||||
{ keys: "c", description: "Compose new message", group: "Mail", handler: () => void useCompose.getState().open() },
|
||||
{ keys: "?", description: "Show keyboard shortcuts", group: "Navigation", handler: onHelp },
|
||||
{ keys: "g i", description: "Go to Inbox", group: "Navigation", handler: go("inbox") },
|
||||
{ keys: "g s", description: "Go to Starred", group: "Navigation", handler: () => navigate("/search?q=is:starred") },
|
||||
{ keys: "g t", description: "Go to Sent", group: "Navigation", handler: go("sent") },
|
||||
{ keys: "g d", description: "Go to Drafts", group: "Navigation", handler: go("drafts") },
|
||||
{ keys: "g a", description: "Go to All mail / Archive", group: "Navigation", handler: () => { const id = useMail.getState().roleId("all") ?? useMail.getState().roleId("archive"); if (id) navigate(`/mail/${id}`); } },
|
||||
{ keys: "g l", description: "Go to Calendar", group: "Navigation", handler: () => navigate("/calendar") },
|
||||
{ keys: "g c", description: "Go to Contacts", group: "Navigation", handler: () => navigate("/contacts") },
|
||||
{ keys: "g f", description: "Go to Files", group: "Navigation", handler: () => navigate("/files") },
|
||||
{ keys: "g k", description: "Go to Settings", group: "Navigation", handler: () => navigate("/settings") },
|
||||
]);
|
||||
}, [navigate, onHelp]);
|
||||
}
|
||||
|
||||
export function ShortcutsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const list = useMemo(() => (open ? keyboard.list() : []), [open]);
|
||||
const groups = useMemo(() => {
|
||||
const g = new Map<string, typeof list>();
|
||||
for (const b of list) {
|
||||
const arr = g.get(b.group) ?? [];
|
||||
arr.push(b);
|
||||
g.set(b.group, arr);
|
||||
}
|
||||
return [...g.entries()];
|
||||
}, [list]);
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title="Keyboard shortcuts" size="lg">
|
||||
<div className="shortcut-grid">
|
||||
{groups.map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<h3>{group}</h3>
|
||||
{items.map((b) => (
|
||||
<div key={b.keys} className="shortcut-row">
|
||||
<span>{b.description}</span>
|
||||
<Kbd keys={b.keys} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
import { useCalendar, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { toLocalDateOnly } from "@/lib/dates";
|
||||
import { formatTime } from "@/lib/format";
|
||||
|
||||
export type CalendarContext =
|
||||
| { kind: "event"; inst: EventInstance; anchor: Anchor }
|
||||
| { kind: "slot"; start: Date; end: Date; allDay: boolean; anchor: Anchor };
|
||||
|
||||
interface Props {
|
||||
ctx: CalendarContext;
|
||||
onClose: () => void;
|
||||
onOpen: (inst: EventInstance, anchor: Anchor) => void;
|
||||
onEdit: (inst: EventInstance) => void;
|
||||
onCreate: (start: Date, end: Date, allDay: boolean) => void;
|
||||
}
|
||||
|
||||
/** Resolve the display colour of an event: explicit colour → category colour → calendar colour. */
|
||||
export function eventColor(ev: CalendarEvent, calendarColor: string | null | undefined, categories: Array<{ name: string; color: string }>): string {
|
||||
if (ev.color) return ev.color;
|
||||
const cat = categoryOf(ev, categories);
|
||||
if (cat) return cat.color;
|
||||
return calendarColor ?? "var(--accent)";
|
||||
}
|
||||
|
||||
export function categoryOf(ev: CalendarEvent, categories: Array<{ name: string; color: string }>): { name: string; color: string } | undefined {
|
||||
const names = Object.keys(ev.categories ?? {});
|
||||
for (const n of names) {
|
||||
const c = categories.find((x) => x.name.toLowerCase() === n.toLowerCase());
|
||||
if (c) return c;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: Props) {
|
||||
const cal = useCalendar();
|
||||
const [, navigate] = useLocation();
|
||||
const categories = useSettings((s) => s.settings.eventCategories);
|
||||
|
||||
if (ctx.kind === "slot") {
|
||||
const { start, end, allDay } = ctx;
|
||||
return (
|
||||
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
|
||||
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
|
||||
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label="New all-day event" onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
|
||||
<MenuSep />
|
||||
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
|
||||
<MenuItem icon={<CalIcon size={16} />} label="Go to week" onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const { inst } = ctx;
|
||||
const ev = inst.event;
|
||||
const baseId = ev.baseEventId ?? ev.id;
|
||||
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
|
||||
const currentCat = categoryOf(ev, categories);
|
||||
const participants = Object.keys(ev.participants ?? {}).length;
|
||||
|
||||
const patch = async (p: Record<string, unknown>, msg: string) => {
|
||||
try {
|
||||
await cal.updateEvent(baseId, p, false);
|
||||
toast.success(msg);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Colour reset");
|
||||
const setCategory = (cat: { name: string; color: string } | null) => {
|
||||
const categoriesPatch = cat ? { [cat.name]: true } : null;
|
||||
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? `Categorised as ${cat.name}` : "Category cleared");
|
||||
};
|
||||
const duplicate = async () => {
|
||||
const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>;
|
||||
try {
|
||||
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
|
||||
toast.success("Event duplicated");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const del = async () => {
|
||||
onClose();
|
||||
const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId);
|
||||
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await cal.destroyEvent(baseId, participants > 1);
|
||||
toast.success("Event deleted");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover anchor={ctx.anchor} onClose={onClose} width={260} closeOnClick={false}>
|
||||
<MenuItem icon={<ExternalLink size={16} />} label="Open" onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
|
||||
{canEdit && <MenuItem icon={<Pencil size={16} />} label="Edit…" onClick={() => { onClose(); onEdit(inst); }} />}
|
||||
{canEdit && <MenuItem icon={<Copy size={16} />} label="Duplicate" onClick={() => { onClose(); void duplicate(); }} />}
|
||||
{canEdit && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuTitle><span className="row gap-4"><Tag size={12} /> Category</span></MenuTitle>
|
||||
{categories.map((c) => (
|
||||
<MenuItem key={c.name} label={<span className="row gap-8"><span className="label-dot" style={{ background: c.color, width: 12, height: 12 }} />{c.name}</span>} checked={currentCat?.name === c.name} onClick={() => { onClose(); setCategory(currentCat?.name === c.name ? null : c); }} />
|
||||
))}
|
||||
<MenuItem icon={<X size={16} />} label="No category" disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Manage categories…" onClick={() => { onClose(); navigate("/settings/calendar"); }} />
|
||||
<MenuSep />
|
||||
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
|
||||
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
|
||||
{CALENDAR_COLORS.map((c) => (
|
||||
<button key={c} type="button" style={{ background: c, width: 26, height: 26, outline: ev.color?.toLowerCase() === c ? "2px solid var(--fg)" : undefined, outlineOffset: 1 }} aria-label={c} onClick={() => { onClose(); setColor(c); }} />
|
||||
))}
|
||||
</div>
|
||||
{ev.color && <MenuItem icon={<X size={16} />} label="Use calendar colour" onClick={() => { onClose(); setColor(null); }} />}
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" onClick={() => void del()} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import type { Calendar } from "@/jmap/types";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { ColorSwatches } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||
|
||||
export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calendar>; onClose: () => void }) {
|
||||
const cal = useCalendar();
|
||||
const [name, setName] = useState(calendar.name ?? "");
|
||||
const [color, setColor] = useState(calendar.color ?? "#0f766e");
|
||||
const [description, setDescription] = useState(calendar.description ?? "");
|
||||
const [tz, setTz] = useState(calendar.timeZone ?? "");
|
||||
const [avail, setAvail] = useState<Calendar["includeInAvailability"]>(calendar.includeInAvailability ?? "all");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const save = async () => {
|
||||
if (!name.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const data: Partial<Calendar> = { name: name.trim(), color, description: description || null, timeZone: tz || null, includeInAvailability: avail };
|
||||
if (calendar.id) await cal.updateCalendar(calendar.id, data);
|
||||
else await cal.createCalendar(data);
|
||||
toast.success("Calendar saved");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={calendar.id ? "Edit calendar" : "New calendar"} size="sm" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>Save</button></>}>
|
||||
<div className="field"><label>Name</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||
<div className="field"><label>Color</label><ColorSwatches value={color} onChange={setColor} /></div>
|
||||
<div className="field"><label>Description</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
|
||||
<div className="field"><label>Time zone</label>
|
||||
<select className="select" value={tz} onChange={(e) => setTz(e.target.value)}>
|
||||
<option value="">Default ({browserTimeZone})</option>
|
||||
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Free/busy</label>
|
||||
<select className="select" value={avail} onChange={(e) => setAvail(e.target.value as Calendar["includeInAvailability"])}>
|
||||
<option value="all">Count all events as busy</option>
|
||||
<option value="attending">Only events I'm attending</option>
|
||||
<option value="none">Don't include in availability</option>
|
||||
</select>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
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 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(() => {
|
||||
const names = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
return [...Array(7)].map((_, i) => names[(weekStart + i) % 7]);
|
||||
}, [weekStart]);
|
||||
|
||||
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>
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react";
|
||||
import { useCalendar, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
||||
import { formatMonthYear, formatTime } from "@/lib/format";
|
||||
import { Empty, useIsMobile } from "@/ui/misc";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { EventPopover } from "./EventPopover";
|
||||
import { EventEditor, type EditorInit } from "./EventEditor";
|
||||
import type { Anchor } from "@/ui/popover";
|
||||
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
|
||||
|
||||
type View = "month" | "week" | "day" | "agenda";
|
||||
const HOUR_H = 48;
|
||||
|
||||
export function CalendarView({ view: viewParam, date }: { view?: string; date?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const cal = useCalendar();
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const isMobile = useIsMobile();
|
||||
const view: View = (["month", "week", "day", "agenda"].includes(viewParam ?? "") ? viewParam : settings.calendarDefaultView) as View;
|
||||
const anchor = useMemo(() => {
|
||||
const d = date ? new Date(`${date}T00:00:00`) : new Date();
|
||||
return Number.isNaN(d.getTime()) ? startOfDay(new Date()) : startOfDay(d);
|
||||
}, [date]);
|
||||
const [popover, setPopover] = useState<{ inst: EventInstance; anchor: Anchor } | null>(null);
|
||||
const [editor, setEditor] = useState<EditorInit | null>(null);
|
||||
const [ctx, setCtx] = useState<CalendarContext | null>(null);
|
||||
const weekStart = settings.weekStart;
|
||||
const effectiveView: View = isMobile && view === "week" ? "day" : view;
|
||||
|
||||
// Range to load
|
||||
const range = useMemo(() => {
|
||||
if (effectiveView === "month") {
|
||||
const g = monthGrid(anchor, weekStart);
|
||||
return { start: g[0]!, end: addDays(g[41]!, 1) };
|
||||
}
|
||||
if (effectiveView === "week") {
|
||||
const s = startOfWeek(anchor, weekStart);
|
||||
return { start: s, end: addDays(s, 7) };
|
||||
}
|
||||
if (effectiveView === "day") return { start: anchor, end: addDays(anchor, 1) };
|
||||
return { start: anchor, end: addDays(anchor, 60) };
|
||||
}, [effectiveView, anchor, weekStart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cal.available) void cal.loadRange(range.start, range.end);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cal.available, range.start.getTime(), range.end.getTime()]);
|
||||
|
||||
const go = useCallback((v: View, d: Date) => navigate(`/calendar/${v}/${toLocalDateOnly(d)}`), [navigate]);
|
||||
const step = (n: number) => {
|
||||
if (effectiveView === "month") go(view, addMonths(anchor, n));
|
||||
else if (effectiveView === "week") go(view, addDays(anchor, 7 * n));
|
||||
else if (effectiveView === "day") go(view, addDays(anchor, n));
|
||||
else go(view, addDays(anchor, 30 * n));
|
||||
};
|
||||
|
||||
const openNew = useCallback(
|
||||
(start?: Date, end?: Date, allDay = false) => {
|
||||
const s = start ?? roundToNext(new Date(), 30);
|
||||
const e = end ?? new Date(s.getTime() + settings.defaultEventDuration * 60_000);
|
||||
setEditor({ start: s, end: e, allDay });
|
||||
},
|
||||
[settings.defaultEventDuration],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onNew = () => openNew();
|
||||
window.addEventListener("ihm:new-event", onNew);
|
||||
return () => window.removeEventListener("ihm:new-event", onNew);
|
||||
}, [openNew]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
keyboard.pushScope("calendar", [
|
||||
{ keys: "t", description: "Today", group: "Calendar", handler: () => go(view, new Date()) },
|
||||
{ keys: "n", description: "Next period", group: "Calendar", handler: () => step(1) },
|
||||
{ keys: "p", description: "Previous period", group: "Calendar", handler: () => step(-1) },
|
||||
{ keys: "d", description: "Day view", group: "Calendar", handler: () => go("day", anchor) },
|
||||
{ keys: "w", description: "Week view", group: "Calendar", handler: () => go("week", anchor) },
|
||||
{ keys: "m", description: "Month view", group: "Calendar", handler: () => go("month", anchor) },
|
||||
{ keys: "a", description: "Agenda view", group: "Calendar", handler: () => go("agenda", anchor) },
|
||||
{ keys: "c", description: "New event", group: "Calendar", handler: () => openNew() },
|
||||
]),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[view, anchor, openNew],
|
||||
);
|
||||
|
||||
if (!cal.available) {
|
||||
return <div className="p-16"><Empty icon={<CalIcon size={40} />} title="Calendar is not available">This account does not have the JMAP calendars capability.</Empty></div>;
|
||||
}
|
||||
|
||||
const title =
|
||||
effectiveView === "month" ? formatMonthYear(anchor)
|
||||
: effectiveView === "week" ? `${range.start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${addDays(range.end, -1).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`
|
||||
: effectiveView === "day" ? anchor.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
||||
: `Agenda from ${anchor.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
|
||||
|
||||
const onEvent = (inst: EventInstance, el: Element) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setPopover({ inst, anchor: { x: r.left, y: r.top, w: r.width, h: r.height } });
|
||||
};
|
||||
const onEventContext = (inst: EventInstance, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPopover(null);
|
||||
setCtx({ kind: "event", inst, anchor: { x: e.clientX, y: e.clientY, w: 0, h: 0 } });
|
||||
};
|
||||
const onSlotContext = (start: Date, end: Date, allDay: boolean, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setCtx({ kind: "slot", start, end, allDay, anchor: { x: e.clientX, y: e.clientY, w: 0, h: 0 } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cal-main">
|
||||
<div className="cal-toolbar">
|
||||
<button className="btn btn-sm" onClick={() => go(view, new Date())}>Today</button>
|
||||
<button className="icon-btn sm" onClick={() => step(-1)} aria-label="Previous"><ChevronLeft size={18} /></button>
|
||||
<button className="icon-btn sm" onClick={() => step(1)} aria-label="Next"><ChevronRight size={18} /></button>
|
||||
<h2 className="truncate">{title}</h2>
|
||||
<span className="spacer" />
|
||||
{cal.loading && <span className="spinner" />}
|
||||
<div className="view-switch">
|
||||
{(["day", "week", "month", "agenda"] as View[]).filter((v) => !(isMobile && v === "week")).map((v) => (
|
||||
<button key={v} className={effectiveView === v ? "active" : ""} onClick={() => go(v, anchor)}>{v[0]!.toUpperCase() + v.slice(1)}</button>
|
||||
))}
|
||||
</div>
|
||||
{!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> Event</button>}
|
||||
</div>
|
||||
{cal.error && <div className="error-box" style={{ margin: 12 }}>{cal.error}</div>}
|
||||
{effectiveView === "month" && <MonthView anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />}
|
||||
{(effectiveView === "week" || effectiveView === "day") && <TimeGrid days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
|
||||
{effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
|
||||
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
|
||||
{isMobile && <button className="fab" aria-label="New event" onClick={() => openNew()}><Plus size={24} /></button>}
|
||||
{popover && <EventPopover inst={popover.inst} anchor={popover.anchor} onClose={() => setPopover(null)} onEdit={() => { setEditor({ event: popover.inst.event, start: popover.inst.start, end: popover.inst.end, allDay: popover.inst.allDay }); setPopover(null); }} />}
|
||||
{editor && <EventEditor init={editor} onClose={() => setEditor(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Month ---------------- */
|
||||
|
||||
type EvCtx = (i: EventInstance, e: React.MouseEvent) => void;
|
||||
type SlotCtx = (start: Date, end: Date, allDay: boolean, e: React.MouseEvent) => void;
|
||||
|
||||
function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotContext, onCreate }: { anchor: Date; weekStart: number; onDay: (d: Date) => void; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (d: Date) => void }) {
|
||||
const cal = useCalendar();
|
||||
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
||||
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
|
||||
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
||||
const dow = weeks[0]!.map((d) => d.toLocaleDateString(undefined, { weekday: "short" }));
|
||||
const maxPer = 4;
|
||||
return (
|
||||
<div className="month-grid">
|
||||
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
|
||||
{weeks.map((days, wi) => (
|
||||
<div key={wi} className="week-row">
|
||||
{days.map((d) => {
|
||||
const dayEnd = addDays(d, 1);
|
||||
const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
|
||||
const shown = evs.slice(0, maxPer);
|
||||
return (
|
||||
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
|
||||
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.getDate()}</span>
|
||||
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
|
||||
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusClass(i: EventInstance): string {
|
||||
const ev = i.event;
|
||||
const mine = useCalendar.getState().identities;
|
||||
const ids = mine.flatMap((m) => [m.calendarAddress.toLowerCase(), ...Object.values(m.sendTo ?? {}).map((x) => x.toLowerCase())]);
|
||||
let my: string | undefined;
|
||||
for (const p of Object.values(ev.participants ?? {})) {
|
||||
const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase());
|
||||
if (addrs.some((a) => ids.includes(a))) my = p.participationStatus;
|
||||
}
|
||||
if (ev.status === "cancelled") return "cancelled";
|
||||
if (my === "declined") return "declined";
|
||||
if (my === "tentative" || my === "needs-action" || ev.status === "tentative") return "tentative";
|
||||
return "";
|
||||
}
|
||||
|
||||
function useEventColor() {
|
||||
const categories = useSettings((s) => s.settings.eventCategories);
|
||||
return (inst: EventInstance) => eventColor(inst.event, inst.calendar?.color, categories);
|
||||
}
|
||||
|
||||
function EventChip({ inst, day, onClick, onContext }: { inst: EventInstance; day: Date; onClick: (el: Element) => void; onContext?: (e: React.MouseEvent) => void }) {
|
||||
const color = useEventColor()(inst);
|
||||
const spansDay = inst.allDay || inst.end.getTime() - inst.start.getTime() >= DAY_MS || !isSameDay(inst.start, inst.end) && inst.start < day;
|
||||
return (
|
||||
<div className={`ev-chip ${spansDay ? "" : "timed"} ${statusClass(inst)}`} style={{ background: color, borderColor: color }} onClick={(e) => { e.stopPropagation(); onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}>
|
||||
{!spansDay && <span className="ev-dot" style={{ background: color }} />}
|
||||
{!spansDay && <span className="ev-time">{formatTime(inst.start)}</span>}
|
||||
<span className="truncate">{inst.event.title || "(untitled)"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Week / Day ---------------- */
|
||||
|
||||
function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDayHeader, workStart, workEnd }: { days: Date[]; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (s: Date, e: Date, allDay: boolean) => void; onDayHeader: (d: Date) => void; workStart: number; workEnd: number }) {
|
||||
const cal = useCalendar();
|
||||
const colorOf = useEventColor();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const start = days[0]!;
|
||||
const end = addDays(days[days.length - 1]!, 1);
|
||||
const instances = cal.instancesIn(start, end);
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [drag, setDrag] = useState<{ day: Date; startMin: number; endMin: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => window.clearInterval(t);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
// scroll to 7am-ish on mount
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = Math.max(0, (Math.min(workStart, 8) - 0.5) * HOUR_H);
|
||||
}, [workStart, days.length]);
|
||||
|
||||
const allDay = (d: Date) => instances.filter((i) => (i.allDay || i.end.getTime() - i.start.getTime() >= DAY_MS) && i.start < addDays(d, 1) && i.end > d);
|
||||
const timed = (d: Date) => instances.filter((i) => !(i.allDay || i.end.getTime() - i.start.getTime() >= DAY_MS) && i.start < addDays(d, 1) && i.end > d);
|
||||
|
||||
const minutesFromEvent = (e: React.MouseEvent, col: HTMLElement) => {
|
||||
const r = col.getBoundingClientRect();
|
||||
const y = e.clientY - r.top + 0; // col is full height
|
||||
return Math.max(0, Math.min(24 * 60, Math.round((y / HOUR_H) * 60 / 15) * 15));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="week-view" style={{ "--cols": days.length } as React.CSSProperties}>
|
||||
<div className="week-head">
|
||||
<div />
|
||||
{days.map((d) => (
|
||||
<div key={d.toISOString()} className={`wh-day ${isToday(d) ? "today" : ""}`} onClick={() => onDayHeader(d)}>
|
||||
<div className="dow">{d.toLocaleDateString(undefined, { weekday: "short" })}</div>
|
||||
<div className="dnum">{d.getDate()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="week-allday">
|
||||
<div className="ad-label">all-day</div>
|
||||
{days.map((d) => (
|
||||
<div key={d.toISOString()} className="ad-cell" onClick={() => onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}>
|
||||
{allDay(d).map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="week-scroll" ref={scrollRef}>
|
||||
<div className="week-body" style={{ "--hour-h": `${HOUR_H}px` } as React.CSSProperties}>
|
||||
<div className="time-col">
|
||||
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: "numeric" })}</span>)}
|
||||
</div>
|
||||
{days.map((d) => {
|
||||
const evs = layoutOverlaps(timed(d), d);
|
||||
const today = isToday(d);
|
||||
const nowTop = ((now.getHours() * 60 + now.getMinutes()) / 60) * HOUR_H;
|
||||
return (
|
||||
<div
|
||||
key={d.toISOString()}
|
||||
className={`day-col ${today ? "today" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest(".ev-block")) return;
|
||||
const m = minutesFromEvent(e, e.currentTarget);
|
||||
setDrag({ day: d, startMin: m, endMin: m + 30 });
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (!drag || !isSameDay(drag.day, d)) return;
|
||||
const m = minutesFromEvent(e, e.currentTarget);
|
||||
setDrag({ ...drag, endMin: Math.max(drag.startMin + 15, m) });
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!drag || !isSameDay(drag.day, d)) return;
|
||||
const s = new Date(d.getTime() + drag.startMin * 60_000);
|
||||
const e2 = new Date(d.getTime() + drag.endMin * 60_000);
|
||||
setDrag(null);
|
||||
onCreate(s, e2, false);
|
||||
}}
|
||||
onMouseLeave={() => { if (drag && isSameDay(drag.day, d)) { const s = new Date(d.getTime() + drag.startMin * 60_000); const e2 = new Date(d.getTime() + drag.endMin * 60_000); setDrag(null); onCreate(s, e2, false); } }}
|
||||
onContextMenu={(e) => {
|
||||
if ((e.target as HTMLElement).closest(".ev-block")) return;
|
||||
const m = minutesFromEvent(e, e.currentTarget);
|
||||
const st = new Date(d.getTime() + Math.floor(m / 30) * 30 * 60_000);
|
||||
onSlotContext(st, new Date(st.getTime() + 60 * 60_000), false, e);
|
||||
}}
|
||||
>
|
||||
<div className="work-hours" style={{ top: workStart * HOUR_H, height: Math.max(0, workEnd - workStart) * HOUR_H }} />
|
||||
{[...Array(24)].map((_, h) => <div key={h} className="hour-line" style={{ top: h * HOUR_H }} />)}
|
||||
{[...Array(24)].map((_, h) => <div key={`h${h}`} className="half-line" style={{ top: h * HOUR_H + HOUR_H / 2 }} />)}
|
||||
{today && <div className="now-line" style={{ top: nowTop }} />}
|
||||
{evs.map(({ inst, top, height, left, width }) => {
|
||||
const color = colorOf(inst);
|
||||
return (
|
||||
<div key={inst.key} className={`ev-block ${statusClass(inst)}`} style={{ top, height: Math.max(height, 18), left: `${left}%`, width: `calc(${width}% - 3px)`, background: color }} onClick={(e) => { e.stopPropagation(); onEvent(inst, e.currentTarget); }} onContextMenu={(e) => onEventContext(inst, e)} title={inst.event.title ?? ""}>
|
||||
<div className="ev-title">{inst.event.title || "(untitled)"}</div>
|
||||
{height > 30 && <div className="ev-time">{formatTime(inst.start)} – {formatTime(inst.end)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{drag && isSameDay(drag.day, d) && (
|
||||
<div className="ev-block draft-new" style={{ top: (drag.startMin / 60) * HOUR_H, height: ((drag.endMin - drag.startMin) / 60) * HOUR_H, left: 0, width: "calc(100% - 3px)", background: "var(--accent)" }}>
|
||||
<div className="ev-title">(new event)</div>
|
||||
<div className="ev-time">{formatTime(new Date(d.getTime() + drag.startMin * 60_000))} – {formatTime(new Date(d.getTime() + drag.endMin * 60_000))}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Simple column layout for overlapping events. */
|
||||
function layoutOverlaps(evs: EventInstance[], day: Date): Array<{ inst: EventInstance; top: number; height: number; left: number; width: number }> {
|
||||
const dayStart = day.getTime();
|
||||
const dayEnd = dayStart + DAY_MS;
|
||||
const items = evs
|
||||
.map((inst) => {
|
||||
const s = Math.max(inst.start.getTime(), dayStart);
|
||||
const e = Math.min(inst.end.getTime(), dayEnd);
|
||||
return { inst, s, e, col: 0, cols: 1 };
|
||||
})
|
||||
.sort((a, b) => a.s - b.s || b.e - a.e);
|
||||
// Greedy column assignment within clusters
|
||||
const clusters: Array<typeof items> = [];
|
||||
let cur: typeof items = [];
|
||||
let curEnd = -1;
|
||||
for (const it of items) {
|
||||
if (cur.length && it.s >= curEnd) {
|
||||
clusters.push(cur);
|
||||
cur = [];
|
||||
curEnd = -1;
|
||||
}
|
||||
cur.push(it);
|
||||
curEnd = Math.max(curEnd, it.e);
|
||||
}
|
||||
if (cur.length) clusters.push(cur);
|
||||
for (const cl of clusters) {
|
||||
const colEnds: number[] = [];
|
||||
for (const it of cl) {
|
||||
let c = colEnds.findIndex((end) => end <= it.s);
|
||||
if (c < 0) {
|
||||
c = colEnds.length;
|
||||
colEnds.push(0);
|
||||
}
|
||||
colEnds[c] = it.e;
|
||||
it.col = c;
|
||||
}
|
||||
for (const it of cl) it.cols = colEnds.length;
|
||||
}
|
||||
return items.map((it) => ({
|
||||
inst: it.inst,
|
||||
top: ((it.s - dayStart) / 3_600_000) * HOUR_H,
|
||||
height: ((it.e - it.s) / 3_600_000) * HOUR_H,
|
||||
left: (it.col / it.cols) * 100,
|
||||
width: 100 / it.cols,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---------------- Agenda ---------------- */
|
||||
|
||||
function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx }) {
|
||||
const cal = useCalendar();
|
||||
const colorOf = useEventColor();
|
||||
const end = addDays(start, 60);
|
||||
const instances = cal.instancesIn(start, end);
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map<string, { day: Date; items: EventInstance[] }>();
|
||||
for (const i of instances) {
|
||||
let d = startOfDay(i.start < start ? start : i.start);
|
||||
const last = startOfDay(new Date(i.end.getTime() - 1));
|
||||
while (d <= last && d < end) {
|
||||
const k = toLocalDateOnly(d);
|
||||
const e = map.get(k) ?? { day: new Date(d), items: [] };
|
||||
e.items.push(i);
|
||||
map.set(k, e);
|
||||
d = addDays(d, 1);
|
||||
if (!i.allDay && isSameDay(i.start, i.end)) break;
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.day.getTime() - b.day.getTime());
|
||||
}, [instances, start, end]);
|
||||
if (!byDay.length) return <Empty icon={<CalIcon size={36} />} title="Nothing scheduled">No events in the next 60 days.</Empty>;
|
||||
return (
|
||||
<div className="agenda">
|
||||
{byDay.map(({ day, items }) => (
|
||||
<div key={day.toISOString()} className="agenda-day">
|
||||
<div className={`ad-date ${isToday(day) ? "today" : ""}`}>
|
||||
{day.toLocaleDateString(undefined, { weekday: "long" })}
|
||||
<small>{day.toLocaleDateString(undefined, { month: "long", day: "numeric" })}</small>
|
||||
</div>
|
||||
<div>
|
||||
{items.map((i) => (
|
||||
<div key={i.key + day.toISOString()} className={`agenda-ev ${statusClass(i)}`} onClick={(e) => onEvent(i, e.currentTarget)} onContextMenu={(e) => onEventContext(i, e)}>
|
||||
<span className="ev-dot" style={{ background: colorOf(i) }} />
|
||||
<span className="ev-when">{i.allDay ? "All day" : `${formatTime(i.start)} – ${formatTime(i.end)}`}</span>
|
||||
<span className="grow truncate">{i.event.title || "(untitled)"}</span>
|
||||
{Object.values(i.event.locations ?? {})[0]?.name && <span className="hint truncate" style={{ maxWidth: 200 }}>{Object.values(i.event.locations ?? {})[0]!.name}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { endOfDay };
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Trash2, Users } from "lucide-react";
|
||||
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
import { useCalendar, myParticipantKeys } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { ColorSwatches, Switch } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { RecipientInput } from "../compose/RecipientInput";
|
||||
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
|
||||
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
||||
import { newKey } from "@/lib/contacts";
|
||||
|
||||
export interface EditorInit {
|
||||
event?: CalendarEvent;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
|
||||
|
||||
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
|
||||
const cal = useCalendar();
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const session = useSession((s) => s.session);
|
||||
const [base, setBase] = useState<CalendarEvent | null | undefined>(init.event && !init.event.baseEventId ? init.event : undefined);
|
||||
const editing = Boolean(init.event);
|
||||
|
||||
// Load base event for recurring instances
|
||||
useEffect(() => {
|
||||
if (init.event?.baseEventId) void cal.getEvent(init.event.baseEventId).then((e) => setBase(e));
|
||||
else if (!init.event) setBase(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [init.event?.id]);
|
||||
|
||||
if (base === undefined) return null;
|
||||
return <EventForm key={base?.id ?? "new"} init={init} base={base} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
|
||||
}
|
||||
|
||||
function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
|
||||
const cal = useCalendar();
|
||||
const contacts = useContacts();
|
||||
const ev = base;
|
||||
const calendars = Object.values(cal.calendars).filter((c) => c.myRights.mayWriteAll || c.myRights.mayWriteOwn);
|
||||
const initialCal = ev ? Object.keys(ev.calendarIds)[0] : (calendars.find((c) => c.isDefault)?.id ?? calendars[0]?.id);
|
||||
const evTz = ev?.timeZone ?? settingsTz;
|
||||
const baseStart = ev ? zonedToDate(ev.start, ev.showWithoutTime ? null : evTz) : init.start;
|
||||
const baseEnd = ev ? new Date(baseStart.getTime() + (parseDuration(ev.duration) || (ev.showWithoutTime ? 86400 : 3600)) * 1000) : init.end;
|
||||
|
||||
const [title, setTitle] = useState(ev?.title ?? "");
|
||||
const [calendarId, setCalendarId] = useState(initialCal ?? "");
|
||||
const [allDay, setAllDay] = useState(ev ? Boolean(ev.showWithoutTime) : init.allDay);
|
||||
const [start, setStart] = useState(baseStart);
|
||||
const [end, setEnd] = useState(baseEnd);
|
||||
const [tz, setTz] = useState(evTz);
|
||||
const [location, setLocation] = useState(Object.values(ev?.locations ?? {})[0]?.name ?? "");
|
||||
const [vurl, setVurl] = useState(Object.values(ev?.virtualLocations ?? {})[0]?.uri ?? "");
|
||||
const [description, setDescription] = useState(ev?.description ?? "");
|
||||
const [status, setStatus] = useState<NonNullable<CalendarEvent["status"]>>(ev?.status ?? "confirmed");
|
||||
const [privacy, setPrivacy] = useState<NonNullable<CalendarEvent["privacy"]>>(ev?.privacy ?? "public");
|
||||
const [freeBusy, setFreeBusy] = useState<NonNullable<CalendarEvent["freeBusyStatus"]>>(ev?.freeBusyStatus ?? "busy");
|
||||
const [color, setColor] = useState<string | null>(ev?.color ?? null);
|
||||
const categories = useSettings((s) => s.settings.eventCategories);
|
||||
const [category, setCategory] = useState<string>(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? "");
|
||||
const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev?.recurrenceRules?.[0]);
|
||||
const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev?.recurrenceRules?.[0]));
|
||||
const [alerts, setAlerts] = useState<number[]>(() => {
|
||||
const a = Object.values(ev?.alerts ?? {}).map((x) => ("offset" in x.trigger ? -parseDuration(x.trigger.offset) / 60 : 0)).filter((n) => n >= 0);
|
||||
if (ev) return a;
|
||||
return defaultAlert >= 0 ? [defaultAlert] : [];
|
||||
});
|
||||
const myKeys = ev ? myParticipantKeys(ev, cal.identities) : [];
|
||||
const [attendees, setAttendees] = useState<EmailAddress[]>(() =>
|
||||
Object.entries(ev?.participants ?? {})
|
||||
.filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee))
|
||||
.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" }))
|
||||
.filter((a) => a.email),
|
||||
);
|
||||
const [sendInvites, setSendInvites] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fb, setFb] = useState<Record<string, BusyPeriod[]>>({});
|
||||
const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length)));
|
||||
|
||||
const identity = cal.identities.find((i) => i.isDefault) ?? cal.identities[0];
|
||||
const myAddress = identity?.calendarAddress ?? (myEmail.includes("@") ? `mailto:${myEmail}` : "");
|
||||
const myPlainEmail = myAddress.replace(/^mailto:/i, "");
|
||||
|
||||
// Free/busy lookup for attendees that are directory principals
|
||||
useEffect(() => {
|
||||
if (!attendees.length || !contacts.principalsLoaded) {
|
||||
if (!contacts.principalsLoaded) void contacts.loadPrincipals();
|
||||
return;
|
||||
}
|
||||
const dayStart = new Date(start);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const out: Record<string, BusyPeriod[]> = {};
|
||||
for (const a of attendees) {
|
||||
const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase());
|
||||
if (!p) continue;
|
||||
try {
|
||||
out[a.email] = await cal.availability(p.id, dayStart, dayEnd);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!cancelled) setFb(out);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [attendees.map((a) => a.email).join(","), start.getTime(), contacts.principalsLoaded]);
|
||||
|
||||
const onStartChange = (d: Date) => {
|
||||
if (Number.isNaN(d.getTime())) return;
|
||||
const dur = end.getTime() - start.getTime();
|
||||
setStart(d);
|
||||
setEnd(new Date(d.getTime() + Math.max(dur, allDay ? DAY_MS : 15 * 60_000)));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!calendarId) {
|
||||
toast.error("Choose a calendar");
|
||||
return;
|
||||
}
|
||||
if (end <= start) {
|
||||
toast.error("End must be after start");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const s = allDay ? new Date(start.getFullYear(), start.getMonth(), start.getDate()) : start;
|
||||
let e = allDay ? new Date(end.getFullYear(), end.getMonth(), end.getDate()) : end;
|
||||
if (allDay && e <= s) e = new Date(s.getTime() + DAY_MS);
|
||||
const participants: Record<string, JSCalendarParticipant> = {};
|
||||
if (attendees.length && myAddress) {
|
||||
participants.me = { "@type": "Participant", name: identity?.name || undefined, email: myPlainEmail, sendTo: { imip: myAddress }, kind: "individual", roles: { owner: true, attendee: true }, participationStatus: "accepted", expectReply: false };
|
||||
for (const a of attendees) {
|
||||
// preserve existing status if the attendee was already there
|
||||
const existing = Object.values(ev?.participants ?? {}).find((p) => (p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, ""))?.toLowerCase() === a.email.toLowerCase());
|
||||
participants[newKey("p")] = { "@type": "Participant", name: a.name ?? undefined, email: a.email, sendTo: { imip: `mailto:${a.email}` }, kind: "individual", roles: { attendee: true }, participationStatus: existing?.participationStatus ?? "needs-action", expectReply: true };
|
||||
}
|
||||
}
|
||||
const alertObj: Record<string, JSCalendarAlert> = {};
|
||||
for (const m of alerts) alertObj[newKey("a")] = { "@type": "Alert", trigger: { "@type": "OffsetTrigger", offset: formatDuration(-m * 60), relativeTo: "start" }, action: "display" };
|
||||
const obj: Record<string, unknown> = {
|
||||
title: title.trim() || "(untitled)",
|
||||
description: description.trim() || undefined,
|
||||
showWithoutTime: allDay,
|
||||
start: allDay ? `${toLocalDateOnly(s)}T00:00:00` : dateToZonedLocal(s, tz),
|
||||
timeZone: allDay ? null : tz,
|
||||
duration: formatDuration(Math.round((e.getTime() - s.getTime()) / 1000)),
|
||||
locations: location.trim() ? { [newKey("l")]: { "@type": "Location", name: location.trim() } } : undefined,
|
||||
virtualLocations: vurl.trim() ? { [newKey("v")]: { "@type": "VirtualLocation", uri: vurl.trim(), name: "Online meeting" } } : undefined,
|
||||
participants: Object.keys(participants).length ? participants : undefined,
|
||||
replyTo: Object.keys(participants).length && myAddress ? { imip: myAddress } : undefined,
|
||||
alerts: Object.keys(alertObj).length ? alertObj : undefined,
|
||||
useDefaultAlerts: false,
|
||||
recurrenceRules: rule ? [rule] : undefined,
|
||||
status,
|
||||
privacy,
|
||||
freeBusyStatus: freeBusy,
|
||||
color: color ?? (category ? categories.find((c) => c.name === category)?.color : undefined),
|
||||
categories: category ? { [category]: true } : undefined,
|
||||
};
|
||||
const invites = sendInvites && attendees.length > 0;
|
||||
if (ev) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
|
||||
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
||||
await cal.updateEvent(ev.id, patch, invites);
|
||||
toast.success("Event updated");
|
||||
} else {
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
|
||||
await cal.createEvent(clean as Partial<CalendarEvent>, calendarId, invites);
|
||||
toast.success(invites ? "Event created and invitations sent" : "Event created");
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const customRule = rule ?? { "@type": "RecurrenceRule", frequency: "weekly" as const };
|
||||
const dayWindow = useMemo(() => {
|
||||
const ds = new Date(start);
|
||||
ds.setHours(0, 0, 0, 0);
|
||||
return { ds, de: new Date(ds.getTime() + DAY_MS) };
|
||||
}, [start]);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
|
||||
<div className="event-form">
|
||||
{init.event?.baseEventId && <div className="info-box mb-16">This is a recurring event — changes apply to the whole series.</div>}
|
||||
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
|
||||
<div className="time-row mb-8">
|
||||
{allDay ? (
|
||||
<>
|
||||
<input className="input" type="date" value={toLocalDateOnly(start)} onChange={(e) => onStartChange(new Date(`${e.target.value}T00:00:00`))} />
|
||||
<span className="muted center">to</span>
|
||||
<input className="input" type="date" value={toLocalDateOnly(new Date(end.getTime() - 1))} onChange={(e) => setEnd(new Date(new Date(`${e.target.value}T00:00:00`).getTime() + DAY_MS))} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input className="input" type="datetime-local" value={toInputDateTime(start)} onChange={(e) => onStartChange(fromInputDateTime(e.target.value))} />
|
||||
<span className="muted center">to</span>
|
||||
<input className="input" type="datetime-local" value={toInputDateTime(end)} onChange={(e) => setEnd(fromInputDateTime(e.target.value))} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="row wrap" style={{ gap: 16, marginBottom: 8 }}>
|
||||
<label className="check"><input type="checkbox" checked={allDay} onChange={(e) => { setAllDay(e.target.checked); if (e.target.checked) { const s = new Date(start); s.setHours(0, 0, 0, 0); setStart(s); setEnd(new Date(s.getTime() + Math.max(DAY_MS, Math.ceil((end.getTime() - s.getTime()) / DAY_MS) * DAY_MS))); } }} /> All day</label>
|
||||
{!allDay && (
|
||||
<select className="select" style={{ width: "auto", height: 32 }} value={tz} onChange={(e) => setTz(e.target.value)} title="Time zone">
|
||||
{!listTimeZones().includes(tz) && <option value={tz}>{tz}</option>}
|
||||
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly on {start.toLocaleDateString(undefined, { weekday: "long" })}</option>
|
||||
<option value="weekdays">Every weekday</option>
|
||||
<option value="monthly">Monthly on day {start.getDate()}</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</div>
|
||||
{preset === "custom" && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="row wrap" style={{ gap: 8 }}>
|
||||
<span>Repeat every</span>
|
||||
<input className="input" type="number" min={1} style={{ width: 70 }} value={customRule.interval ?? 1} onChange={(e) => setRule({ ...customRule, interval: Math.max(1, Number(e.target.value)) })} />
|
||||
<select className="select" style={{ width: "auto" }} value={customRule.frequency} onChange={(e) => setRule({ ...customRule, frequency: e.target.value as JSCalendarRecurrenceRule["frequency"], byDay: e.target.value === "weekly" ? customRule.byDay : undefined, byMonthDay: e.target.value === "monthly" ? [start.getDate()] : undefined })}>
|
||||
<option value="daily">day(s)</option><option value="weekly">week(s)</option><option value="monthly">month(s)</option><option value="yearly">year(s)</option>
|
||||
</select>
|
||||
</div>
|
||||
{customRule.frequency === "weekly" && (
|
||||
<div className="row" style={{ gap: 4, marginTop: 8 }}>
|
||||
{WEEKDAYS.map((w) => {
|
||||
const on = customRule.byDay?.some((d) => d.day === w.key);
|
||||
return <button key={w.key} type="button" className={`btn btn-sm btn-pill ${on ? "btn-primary" : ""}`} style={{ width: 36, padding: 0 }} title={w.label} onClick={() => { const cur = customRule.byDay ?? []; const next: JSCalendarNDay[] = on ? cur.filter((d) => d.day !== w.key) : [...cur, { "@type": "NDay", day: w.key }]; setRule({ ...customRule, byDay: next.length ? next : undefined }); }}>{w.short}</button>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="row wrap" style={{ gap: 8, marginTop: 8 }}>
|
||||
<span>Ends</span>
|
||||
<select className="select" style={{ width: "auto" }} value={customRule.until ? "until" : customRule.count ? "count" : "never"} onChange={(e) => { const v = e.target.value; setRule({ ...customRule, until: v === "until" ? `${toLocalDateOnly(new Date(start.getTime() + 30 * DAY_MS))}T23:59:59` : undefined, count: v === "count" ? 10 : undefined }); }}>
|
||||
<option value="never">never</option><option value="until">on date</option><option value="count">after N times</option>
|
||||
</select>
|
||||
{customRule.until && <input className="input" type="date" style={{ width: "auto" }} value={customRule.until.slice(0, 10)} onChange={(e) => setRule({ ...customRule, until: `${e.target.value}T23:59:59` })} />}
|
||||
{customRule.count && <input className="input" type="number" min={1} style={{ width: 80 }} value={customRule.count} onChange={(e) => setRule({ ...customRule, count: Math.max(1, Number(e.target.value)) })} />}
|
||||
</div>
|
||||
<div className="hint mt-8">{describeRule(customRule)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Calendar</label>
|
||||
<select className="select" value={calendarId} onChange={(e) => setCalendarId(e.target.value)}>
|
||||
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Location</label><input className="input" value={location} onChange={(e) => setLocation(e.target.value)} placeholder="Add location" /></div>
|
||||
</div>
|
||||
<div className="field"><label>Meeting link</label><input className="input" value={vurl} onChange={(e) => setVurl(e.target.value)} placeholder="https://meet.example.com/…" /></div>
|
||||
<div className="field">
|
||||
<label><Users size={13} /> Guests</label>
|
||||
<div className="input" style={{ height: "auto", minHeight: 38, padding: "4px 8px" }}>
|
||||
<RecipientInput value={attendees} onChange={setAttendees} placeholder="Add guests by name or email" />
|
||||
</div>
|
||||
{attendees.length > 0 && (
|
||||
<>
|
||||
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
|
||||
{Object.keys(fb).length > 0 && (
|
||||
<div className="freebusy">
|
||||
<div className="hint">Availability on {start.toLocaleDateString()}</div>
|
||||
{attendees.filter((a) => fb[a.email]).map((a) => (
|
||||
<div key={a.email} className="fb-row">
|
||||
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
|
||||
<div className="fb-bar">
|
||||
{fb[a.email]!.map((b, i) => {
|
||||
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime());
|
||||
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime());
|
||||
if (be <= bs) return null;
|
||||
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${new Date(b.utcStart).toLocaleTimeString()} – ${new Date(b.utcEnd).toLocaleTimeString()}`} />;
|
||||
})}
|
||||
{!allDay && <span className="fb-window" style={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="field"><label>Description</label><textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} /></div>
|
||||
<div className="field">
|
||||
<label>Reminders</label>
|
||||
<div className="alerts-list">
|
||||
{alerts.map((m, i) => (
|
||||
<div key={i} className="row">
|
||||
<select className="select" style={{ width: "auto" }} value={String(m)} onChange={(e) => setAlerts(alerts.map((x, j) => (j === i ? Number(e.target.value) : x)))}>
|
||||
{[...new Set([...ALERT_OPTIONS, m])].sort((a, b) => a - b).map((o) => <option key={o} value={o}>{o === 0 ? "At time of event" : `${humanDuration(o * 60)} before`}</option>)}
|
||||
</select>
|
||||
<button className="icon-btn sm danger" onClick={() => setAlerts(alerts.filter((_, j) => j !== i))} aria-label="Remove reminder"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAlerts([...alerts, 10])}><Plus size={14} /> Add reminder</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setShowMore((v) => !v)}>{showMore ? "Fewer options" : "More options"}</button>
|
||||
{showMore && (
|
||||
<div className="mt-8">
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
|
||||
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
|
||||
<div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>
|
||||
</div>
|
||||
<div className="field"><label>Category</label>
|
||||
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
<option value="">None</option>
|
||||
{categories.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Color</label><div className="row wrap"><ColorSwatches value={color} onChange={setColor} />{color && <button className="btn btn-ghost btn-sm" onClick={() => setColor(null)}>Use {category ? "category" : "calendar"} color</button>}</div></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
|
||||
import { useCalendar, myParticipantKeys, type EventInstance } from "@/store/calendar";
|
||||
import { Popover, type Anchor } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { formatTimeRange, humanDuration, parseDuration } from "@/lib/dates";
|
||||
import { describeRule } from "@/lib/recurrence";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { categoryOf, eventColor } from "./CalendarContextMenu";
|
||||
|
||||
export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventInstance; anchor: Anchor; onClose: () => void; onEdit: () => void }) {
|
||||
const cal = useCalendar();
|
||||
const ev = inst.event;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const categories = useSettings((s) => s.settings.eventCategories);
|
||||
const color = eventColor(ev, inst.calendar?.color, categories);
|
||||
const category = categoryOf(ev, categories);
|
||||
const participants = Object.entries(ev.participants ?? {});
|
||||
const myKeys = myParticipantKeys(ev, cal.identities);
|
||||
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
|
||||
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
|
||||
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
|
||||
const baseId = ev.baseEventId ?? ev.id;
|
||||
const location = Object.values(ev.locations ?? {})[0];
|
||||
const vloc = Object.values(ev.virtualLocations ?? {})[0];
|
||||
const alerts = Object.values(ev.alerts ?? {});
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
const del = async () => {
|
||||
const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId);
|
||||
const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true });
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await cal.destroyEvent(baseId, participants.length > 1);
|
||||
toast.success("Event deleted");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await cal.rsvp(baseId, status);
|
||||
toast.success("Response sent");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover anchor={anchor} onClose={onClose} className="event-popover" closeOnClick={false} side="right" role="dialog" style={{ "--ev-color": color } as React.CSSProperties}>
|
||||
<div className="row" style={{ justifyContent: "flex-end", gap: 0, marginBottom: -4 }}>
|
||||
{canEdit && <button className="icon-btn sm" title="Edit" onClick={onEdit}><Pencil size={16} /></button>}
|
||||
{canEdit && <button className="icon-btn sm danger" title="Delete" onClick={() => void del()} disabled={busy}><Trash2 size={16} /></button>}
|
||||
<button className="icon-btn sm" title="Close" onClick={onClose}><X size={16} /></button>
|
||||
</div>
|
||||
<h3>{ev.title || "(untitled)"}</h3>
|
||||
<div className="ev-line"><Clock size={15} /><span>{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? <span className="hint"> · {ev.timeZone}</span> : null}</span></div>
|
||||
{ev.recurrenceRules?.[0] && <div className="ev-line"><Repeat size={15} /><span>{describeRule(ev.recurrenceRules[0])}</span></div>}
|
||||
{location?.name && <div className="ev-line"><MapPin size={15} /><span>{location.name}</span></div>}
|
||||
{vloc?.uri && <div className="ev-line"><Link2 size={15} /><a href={vloc.uri} target="_blank" rel="noreferrer" className="truncate">{vloc.name || vloc.uri}</a></div>}
|
||||
{ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>}
|
||||
{alerts.length > 0 && <div className="ev-line"><Bell size={15} /><span>{alerts.map((a) => ("offset" in a.trigger ? humanDuration(parseDuration(a.trigger.offset)) + (parseDuration(a.trigger.offset) < 0 ? " before" : " after") : "at " + a.trigger.when)).join(", ")}</span></div>}
|
||||
{category && <div className="ev-line"><span className="label-dot" style={{ background: category.color, width: 12, height: 12, marginTop: 3 }} /><span>{category.name}</span></div>}
|
||||
<div className="ev-line"><CalIcon size={15} /><span>{inst.calendar?.name ?? "Calendar"}{ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}{ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}{ev.freeBusyStatus === "free" ? " · shown as free" : ""}</span></div>
|
||||
{participants.length > 0 && (
|
||||
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
|
||||
<div className="row gap-8"><Users size={15} /><span>{participants.length} participant{participants.length === 1 ? "" : "s"}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
|
||||
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
|
||||
{participants.map(([k, p]) => (
|
||||
<div key={k} className="participant-row">
|
||||
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
|
||||
<span className="truncate">{p.name || p.email || Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "")}</span>
|
||||
{p.roles?.owner && <span className="hint">organizer</span>}
|
||||
{p.roles?.optional && <span className="hint">optional</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{myKeys.length > 0 && !isOrganizer && (
|
||||
<div className="row" style={{ marginTop: 10, gap: 6 }}>
|
||||
<span className="hint">Going?</span>
|
||||
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("accepted")}><Check size={14} /> Yes</button>
|
||||
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("tentative")}><HelpCircle size={14} /> Maybe</button>
|
||||
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={busy} onClick={() => void rsvp("declined")}><X size={14} /> No</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
|
||||
import { useCompose, type Draft } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { RecipientInput } from "./RecipientInput";
|
||||
import { RichEditor, type RichEditorHandle } from "./RichEditor";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { formatSize, formatRelative } from "@/lib/format";
|
||||
import { htmlToText, textToHtml } from "@/lib/text";
|
||||
import { isValidEmail } from "@/lib/address";
|
||||
import { attachmentIcon } from "../mail/MessageView";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function Composer({ draft }: { draft: Draft }) {
|
||||
const update = useCompose((s) => s.update);
|
||||
const close = useCompose((s) => s.close);
|
||||
const send = useCompose((s) => s.send);
|
||||
const saveDraft = useCompose((s) => s.saveDraft);
|
||||
const addFiles = useCompose((s) => s.addFiles);
|
||||
const removeAttachment = useCompose((s) => s.removeAttachment);
|
||||
const setIdentity = useCompose((s) => s.setIdentity);
|
||||
const insertTemplate = useCompose((s) => s.insertTemplate);
|
||||
const focus = useCompose((s) => s.focus);
|
||||
const identities = useMail((s) => s.identities);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const isMobile = useIsMobile();
|
||||
const editorRef = useRef<RichEditorHandle>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const moreMenu = useMenu();
|
||||
const sendMenu = useMenu();
|
||||
const templateMenu = useMenu();
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const d = draft;
|
||||
const key = d.key;
|
||||
|
||||
const patch = useCallback((p: Partial<Draft>) => update(key, p), [update, key]);
|
||||
const onHtml = useCallback((html: string) => update(key, { html }), [update, key]);
|
||||
|
||||
// Esc closes (saves draft); Ctrl+Enter sends
|
||||
useEffect(() => {
|
||||
if (d.minimized) return;
|
||||
return keyboard.pushScope("composer", [
|
||||
{ keys: "mod+enter", description: "Send message", group: "Compose", handler: () => void doSend(), allowInInput: true },
|
||||
{ keys: "esc", description: "Close composer (saves draft)", group: "Compose", handler: () => { if (document.activeElement?.closest(".composer")) { void close(key); return true; } return false; }, allowInInput: true },
|
||||
{ keys: "mod+s", description: "Save draft", group: "Compose", handler: () => { void saveDraft(key); }, allowInInput: true },
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, d.minimized]);
|
||||
|
||||
const bodyText = useMemo(() => (d.format === "html" ? htmlToText(d.html.replace(/<div class="ihm-quote">[\s\S]*$/, "")) : d.text), [d.html, d.text, d.format]);
|
||||
|
||||
const doSend = async () => {
|
||||
const all = [...d.to, ...d.cc, ...d.bcc];
|
||||
if (!all.length) {
|
||||
toast.error("Please add at least one recipient");
|
||||
return;
|
||||
}
|
||||
const bad = all.filter((a) => !isValidEmail(a.email));
|
||||
if (bad.length) {
|
||||
toast.error(`Invalid address: ${bad[0]!.email}`);
|
||||
return;
|
||||
}
|
||||
if (d.attachments.some((a) => a.error)) {
|
||||
toast.error("Remove attachments that failed to upload first");
|
||||
return;
|
||||
}
|
||||
if (d.attachments.some((a) => !a.blobId)) {
|
||||
toast.error("Attachments are still uploading");
|
||||
return;
|
||||
}
|
||||
if (!d.subject.trim()) {
|
||||
const ok = await confirmDialog({ title: "Send without a subject?", confirmLabel: "Send anyway" });
|
||||
if (!ok) return;
|
||||
}
|
||||
if (settings.attachmentReminder && !d.attachments.length && /\b(attach(ed|ment|ing)?|enclosed|anbei|ci-joint|adjunto)\b/i.test(bodyText) ) {
|
||||
const ok = await confirmDialog({ title: "Did you forget the attachment?", message: "Your message mentions an attachment, but nothing is attached.", confirmLabel: "Send anyway" });
|
||||
if (!ok) return;
|
||||
}
|
||||
await send(key);
|
||||
};
|
||||
|
||||
const toggleFormat = () => {
|
||||
if (d.format === "html") {
|
||||
patch({ format: "text", text: htmlToText(d.html) });
|
||||
} else {
|
||||
patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "<br>") });
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDropping(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length) addFiles(key, files);
|
||||
};
|
||||
|
||||
const ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
|
||||
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message");
|
||||
const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : "";
|
||||
const totalSize = d.attachments.reduce((n, a) => n + a.size, 0);
|
||||
|
||||
if (d.minimized) {
|
||||
return (
|
||||
<div className="composer minimized" onClick={() => focus(key)}>
|
||||
<div className="composer-head">
|
||||
<span className="title">{title}</span>
|
||||
<button className="icon-btn sm" aria-label="Restore" onClick={(e) => { e.stopPropagation(); focus(key); }}><Maximize2 size={16} /></button>
|
||||
<button className="icon-btn sm" aria-label="Close" onClick={(e) => { e.stopPropagation(); void close(key); }}><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`composer ${d.maximized ? "maximized" : ""} ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop} role="dialog" aria-label="Compose message">
|
||||
<div className="composer-head" onDoubleClick={() => patch({ maximized: !d.maximized })}>
|
||||
<span className="title">{title}</span>
|
||||
<span className="status">{status}</span>
|
||||
{!isMobile && <button className="icon-btn sm" aria-label="Minimize" title="Minimize" onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
|
||||
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? "Restore" : "Maximize"} title={d.maximized ? "Restore" : "Full screen"} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
|
||||
<button className="icon-btn sm" aria-label="Close" title="Save & close (Esc)" onClick={() => void close(key)}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="composer-body">
|
||||
<div className="composer-fields">
|
||||
{identities.length > 1 && (
|
||||
<div className="composer-field">
|
||||
<label>From</label>
|
||||
<select className="from-select" value={ident?.id ?? ""} onChange={(e) => setIdentity(key, e.target.value)}>
|
||||
{identities.map((i) => <option key={i.id} value={i.id}>{i.name ? `${i.name} <${i.email}>` : i.email}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-to`}>To</label>
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={!d.to.length} />
|
||||
<span className="field-extra">
|
||||
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
|
||||
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
|
||||
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
|
||||
</span>
|
||||
</div>
|
||||
{d.showReplyTo && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-rt`} title="Replies will go to this address instead of the From address">Reply-To</label>
|
||||
<RecipientInput id={`${key}-rt`} value={d.replyTo} onChange={(replyTo) => patch({ replyTo })} placeholder="Replies go to…" />
|
||||
</div>
|
||||
)}
|
||||
{d.showCc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-cc`}>Cc</label>
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
|
||||
</div>
|
||||
)}
|
||||
{d.showBcc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-bcc`}>Bcc</label>
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-subj`} className="sr-only">Subject</label>
|
||||
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={d.to.length > 0 && !d.subject} />
|
||||
{d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>}
|
||||
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
|
||||
</div>
|
||||
</div>
|
||||
{d.format === "html" ? (
|
||||
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder="Write your message…" spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={d.to.length > 0 && Boolean(d.subject)} />
|
||||
) : (
|
||||
<textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder="Write your message…" spellCheck={settings.spellcheck} />
|
||||
)}
|
||||
{d.attachments.some((a) => !a.inline) && (
|
||||
<div className="composer-attachments">
|
||||
{d.attachments.filter((a) => !a.inline).map((a) => (
|
||||
<div key={a.id} className={`attachment ${a.error ? "error" : ""}`} title={a.error ?? a.name}>
|
||||
<span className="att-icon">{attachmentIcon(a.type, a.name)}</span>
|
||||
<span className="att-text">
|
||||
<span className="att-name">{a.name}</span>
|
||||
<span className="att-size">{a.error ? <span style={{ color: "var(--danger)" }}>{a.error}</span> : a.blobId ? formatSize(a.size) : `${a.progress}%`}{a.inline ? " · inline" : ""}</span>
|
||||
</span>
|
||||
<button className="icon-btn xs" aria-label="Remove attachment" onClick={() => removeAttachment(key, a.id)}><X size={14} /></button>
|
||||
{!a.blobId && !a.error && <span className="att-progress" style={{ width: `${a.progress}%` }} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-foot">
|
||||
<span className="send-group">
|
||||
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title="Send (Ctrl+Enter)"><Send size={16} /> Send</button>
|
||||
<button className="btn btn-primary" onClick={sendMenu.open} aria-label="Send options"><ChevronDown size={16} /></button>
|
||||
</span>
|
||||
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={240}>
|
||||
<MenuItem icon={<Send size={16} />} label="Send" kbd="Ctrl+↵" onClick={() => void doSend()} />
|
||||
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
||||
</Popover>
|
||||
<span className="more-actions">
|
||||
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
|
||||
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
|
||||
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title="Formatting options" onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
|
||||
{settings.templates.length > 0 && <button className="icon-btn" title="Insert template" onClick={templateMenu.open}><FileText size={18} /></button>}
|
||||
<Popover anchor={templateMenu.anchor} onClose={templateMenu.close} side="top" width={260}>
|
||||
<MenuTitle>Templates</MenuTitle>
|
||||
{settings.templates.map((t) => <MenuItem key={t.id} label={t.name} onClick={() => insertTemplate(key, t.html, t.subject)} />)}
|
||||
</Popover>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More options"><MoreVertical size={18} /></button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}>
|
||||
<MenuItem icon={<Type size={16} />} label={d.format === "html" ? "Switch to plain text" : "Switch to rich text"} onClick={toggleFormat} />
|
||||
<MenuItem icon={<CheckCheck size={16} />} label="Request read receipt" checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
|
||||
<MenuSep />
|
||||
<MenuTitle>Priority</MenuTitle>
|
||||
<MenuItem label="High" checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
|
||||
<MenuItem label="Normal" checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
|
||||
<MenuItem label="Low" checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<ChevronsDown size={16} />} label="Save as template" onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
|
||||
<MenuItem icon={<FileText size={16} />} label="Save draft now" onClick={() => void saveDraft(key)} />
|
||||
</Popover>
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title="Large attachments may be rejected by some servers"><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
|
||||
<button className="icon-btn danger" title="Discard draft" aria-label="Discard draft" onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { Composer } from "./Composer";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
|
||||
export function ComposerDock() {
|
||||
const drafts = useCompose((s) => s.drafts);
|
||||
const activeKey = useCompose((s) => s.activeKey);
|
||||
const isMobile = useIsMobile();
|
||||
if (!drafts.length) return null;
|
||||
// On mobile only the active composer is shown (full screen); others are minimized bars.
|
||||
const visible = isMobile ? drafts.filter((d) => d.key === activeKey || d.minimized) : drafts;
|
||||
return (
|
||||
<div className="composer-dock">
|
||||
{visible.map((d) => (
|
||||
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
import { isValidEmail, parseAddressList, displayName } from "@/lib/address";
|
||||
import { useContacts, type Suggestion } from "@/store/contacts";
|
||||
import { Avatar } from "@/ui/misc";
|
||||
|
||||
interface Props {
|
||||
value: EmailAddress[];
|
||||
onChange: (v: EmailAddress[]) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sugg, setSugg] = useState<Suggestion[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggest = useContacts((s) => s.suggest);
|
||||
const reqId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const q = text.trim();
|
||||
if (!q) {
|
||||
setSugg([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const id = ++reqId.current;
|
||||
const t = window.setTimeout(() => {
|
||||
void suggest(q).then((list) => {
|
||||
if (id !== reqId.current) return;
|
||||
const existing = new Set(value.map((v) => v.email.toLowerCase()));
|
||||
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
|
||||
setSugg(filtered);
|
||||
setActive(0);
|
||||
setOpen(filtered.length > 0);
|
||||
});
|
||||
}, 120);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [text, suggest, value]);
|
||||
|
||||
const commit = (raw?: string) => {
|
||||
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
|
||||
if (!s) return;
|
||||
const parsed = parseAddressList(s);
|
||||
if (!parsed.length) return;
|
||||
onChange([...value, ...parsed.filter((p) => !value.some((v) => v.email.toLowerCase() === p.email.toLowerCase()))]);
|
||||
setText("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const pick = (s: Suggestion) => {
|
||||
onChange([...value, { name: s.name, email: s.email }]);
|
||||
setText("");
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (open && sugg.length) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => (a + 1) % sugg.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => (a - 1 + sugg.length) % sugg.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (sugg[active]) {
|
||||
e.preventDefault();
|
||||
pick(sugg[active]!);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ";") {
|
||||
if (text.trim()) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Enter") e.preventDefault();
|
||||
} else if (e.key === "Tab" && text.trim()) {
|
||||
commit();
|
||||
} else if (e.key === "Backspace" && !text && value.length) {
|
||||
const last = value[value.length - 1]!;
|
||||
onChange(value.slice(0, -1));
|
||||
setText(last.name ? `${last.name} <${last.email}>` : last.email);
|
||||
}
|
||||
};
|
||||
|
||||
const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||
const t = e.clipboardData.getData("text");
|
||||
if (t && /[,;\n]|<.+@.+>/.test(t)) {
|
||||
e.preventDefault();
|
||||
commit(text + t);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="recipients" onClick={() => inputRef.current?.focus()}>
|
||||
{value.map((a, i) => (
|
||||
<span key={`${a.email}-${i}`} className={`chip ${isValidEmail(a.email) ? "" : "invalid"}`} title={a.email}>
|
||||
<span className="truncate" style={{ maxWidth: 220 }}>{a.name ? displayName(a) : a.email}</span>
|
||||
<button type="button" className="chip-x" aria-label={`Remove ${a.email}`} onClick={(e) => { e.stopPropagation(); onChange(value.filter((_, j) => j !== i)); }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
placeholder={value.length ? "" : placeholder}
|
||||
autoFocus={autoFocus}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
onPaste={onPaste}
|
||||
onBlur={() => { window.setTimeout(() => { setOpen(false); if (text.trim()) commit(); }, 150); }}
|
||||
onFocus={() => sugg.length && setOpen(true)}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
/>
|
||||
{open && (
|
||||
<div className="suggest-list" role="listbox">
|
||||
{sugg.map((s, i) => (
|
||||
<div key={s.email} className={`suggest-item ${i === active ? "active" : ""}`} role="option" aria-selected={i === active} onMouseDown={(e) => { e.preventDefault(); pick(s); }} onMouseEnter={() => setActive(i)}>
|
||||
<Avatar who={s} size="sm" />
|
||||
<div className="col" style={{ minWidth: 0 }}>
|
||||
<span className="s-name truncate">{s.name ?? s.email}</span>
|
||||
{s.name && <span className="s-email truncate">{s.email}</span>}
|
||||
</div>
|
||||
<span className="s-src">{s.source === "gal" ? "Directory" : s.source === "recent" ? "Recent" : ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type ClipboardEvent, type ReactNode } from "react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageIcon, Indent, Italic, Link as LinkIcon, List, ListOrdered, Outdent, Quote, Redo, Smile, Strikethrough, Underline, Undo, Palette, Highlighter, Type } from "lucide-react";
|
||||
import { sanitizeEditorHtml } from "@/lib/html";
|
||||
import { Popover, useMenu } from "@/ui/popover";
|
||||
|
||||
export interface RichEditorHandle {
|
||||
focus(): void;
|
||||
insertHtml(html: string): void;
|
||||
insertText(text: string): void;
|
||||
getHtml(): string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
html: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
spellcheck?: boolean;
|
||||
onFiles?: (files: File[]) => void;
|
||||
toolbarExtra?: ReactNode;
|
||||
showToolbar: boolean;
|
||||
autoFocus?: boolean;
|
||||
/** If provided, inserted images are uploaded and referenced by URL instead of embedded as data: URLs. */
|
||||
imageUpload?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
|
||||
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
|
||||
|
||||
export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEditor({ html, onChange, placeholder, spellcheck = true, onFiles, toolbarExtra, showToolbar, autoFocus, imageUpload }, ref) {
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const lastEmitted = useRef<string>("");
|
||||
const [empty, setEmpty] = useState(!html);
|
||||
const emojiMenu = useMenu();
|
||||
const colorMenu = useMenu();
|
||||
const hiliteMenu = useMenu();
|
||||
const linkMenu = useMenu();
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const savedRange = useRef<Range | null>(null);
|
||||
|
||||
// Sync external html → DOM (only when it differs from what we emitted)
|
||||
useEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
if (html !== lastEmitted.current) {
|
||||
el.innerHTML = html;
|
||||
lastEmitted.current = html;
|
||||
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
// caret at start
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(el, 0);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const emit = useCallback(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
const v = el.innerHTML;
|
||||
lastEmitted.current = v;
|
||||
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
||||
onChange(v);
|
||||
}, [onChange]);
|
||||
|
||||
const exec = useCallback(
|
||||
(cmd: string, value?: string) => {
|
||||
elRef.current?.focus();
|
||||
restoreRange();
|
||||
document.execCommand(cmd, false, value);
|
||||
emit();
|
||||
},
|
||||
[emit],
|
||||
);
|
||||
|
||||
const saveRange = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount && elRef.current?.contains(sel.anchorNode)) savedRange.current = sel.getRangeAt(0).cloneRange();
|
||||
};
|
||||
const restoreRange = () => {
|
||||
const r = savedRange.current;
|
||||
if (!r) return;
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(r);
|
||||
};
|
||||
|
||||
const insertHtml = useCallback(
|
||||
(h: string) => {
|
||||
elRef.current?.focus();
|
||||
restoreRange();
|
||||
document.execCommand("insertHTML", false, h);
|
||||
emit();
|
||||
},
|
||||
[emit],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => elRef.current?.focus(),
|
||||
insertHtml,
|
||||
insertText: (t: string) => {
|
||||
elRef.current?.focus();
|
||||
restoreRange();
|
||||
document.execCommand("insertText", false, t);
|
||||
emit();
|
||||
},
|
||||
getHtml: () => elRef.current?.innerHTML ?? "",
|
||||
}));
|
||||
|
||||
const onPaste = (e: ClipboardEvent<HTMLDivElement>) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const imgItem = items.find((i) => i.type.startsWith("image/"));
|
||||
if (imgItem) {
|
||||
const f = imgItem.getAsFile();
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
insertImageFile(f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const htmlData = e.clipboardData.getData("text/html");
|
||||
if (htmlData) {
|
||||
e.preventDefault();
|
||||
const clean = sanitizeEditorHtml(htmlData).replace(/<meta[^>]*>/gi, "");
|
||||
document.execCommand("insertHTML", false, clean);
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
// plain text: let browser handle (it inserts text nodes) but normalize newlines
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
if (text && /\n/.test(text)) {
|
||||
e.preventDefault();
|
||||
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\r?\n/g, "<br>");
|
||||
document.execCommand("insertHTML", false, escaped);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
|
||||
const insertImageFile = (f: File) => {
|
||||
if (imageUpload) {
|
||||
imageUpload(f)
|
||||
.then((url) => insertHtml(`<img src="${url}" alt="${f.name.replace(/"/g, "")}" style="max-width:100%">`))
|
||||
.catch(() => {
|
||||
/* uploader reports its own errors */
|
||||
});
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
insertHtml(`<img src="${reader.result as string}" alt="${f.name.replace(/"/g, "")}" style="max-width:100%">`);
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const others = files.filter((f) => !f.type.startsWith("image/"));
|
||||
images.forEach(insertImageFile);
|
||||
if (others.length) onFiles?.(others);
|
||||
};
|
||||
|
||||
const applyLink = () => {
|
||||
const url = linkUrl.trim();
|
||||
linkMenu.close();
|
||||
if (!url) return;
|
||||
const href = /^(https?:|mailto:|tel:)/i.test(url) ? url : `https://${url}`;
|
||||
elRef.current?.focus();
|
||||
restoreRange();
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.isCollapsed) document.execCommand("insertHTML", false, `<a href="${href}" target="_blank" rel="noopener">${href}</a>`);
|
||||
else document.execCommand("createLink", false, href);
|
||||
emit();
|
||||
setLinkUrl("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="composer-editor">
|
||||
<div
|
||||
ref={elRef}
|
||||
className="editor-area"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={spellcheck}
|
||||
data-placeholder={placeholder ?? ""}
|
||||
data-empty={empty}
|
||||
onInput={emit}
|
||||
onBlur={saveRange}
|
||||
onKeyUp={saveRange}
|
||||
onMouseUp={saveRange}
|
||||
onPaste={onPaste}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
saveRange();
|
||||
linkMenu.open(e.currentTarget);
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
exec(e.shiftKey ? "outdent" : "indent");
|
||||
}
|
||||
}}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label="Message body"
|
||||
/>
|
||||
{showToolbar && (
|
||||
<div className="editor-toolbar" role="toolbar" aria-label="Formatting">
|
||||
<button type="button" className="icon-btn" title="Undo (Ctrl+Z)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Redo" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("redo")}><Redo size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<select title="Font size" onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
|
||||
<option value="" disabled>Size</option>
|
||||
<option value="1">Small</option>
|
||||
<option value="3">Normal</option>
|
||||
<option value="5">Large</option>
|
||||
<option value="7">Huge</option>
|
||||
</select>
|
||||
<button type="button" className="icon-btn" title="Bold (Ctrl+B)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("bold")}><Bold size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Italic (Ctrl+I)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("italic")}><Italic size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Underline (Ctrl+U)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("underline")}><Underline size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Strikethrough" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("strikeThrough")}><Strikethrough size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Text color" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={colorMenu.open}><Palette size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Highlight" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={hiliteMenu.open}><Highlighter size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<button type="button" className="icon-btn" title="Align left" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyLeft")}><AlignLeft size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Center" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyCenter")}><AlignCenter size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Align right" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyRight")}><AlignRight size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<button type="button" className="icon-btn" title="Bulleted list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertUnorderedList")}><List size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Numbered list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertOrderedList")}><ListOrdered size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Decrease indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("outdent")}><Outdent size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Increase indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("indent")}><Indent size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Quote" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "blockquote")}><Quote size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Code block" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "pre")}><Code size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Normal text" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "div")}><Type size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<button type="button" className="icon-btn" title="Insert link (Ctrl+K)" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={linkMenu.open}><LinkIcon size={16} /></button>
|
||||
<label className="icon-btn" title="Insert image" onMouseDown={saveRange}>
|
||||
<ImageIcon size={16} />
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) insertImageFile(f); e.target.value = ""; }} />
|
||||
</label>
|
||||
<button type="button" className="icon-btn" title="Emoji" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={emojiMenu.open}><Smile size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Remove formatting" onMouseDown={(e) => e.preventDefault()} onClick={() => { exec("removeFormat"); exec("unlink"); }}><Eraser size={16} /></button>
|
||||
{toolbarExtra}
|
||||
</div>
|
||||
)}
|
||||
<Popover anchor={emojiMenu.anchor} onClose={emojiMenu.close} side="top" closeOnClick={false} width={290}>
|
||||
<div className="emoji-grid">
|
||||
{EMOJI.map((e) => (
|
||||
<button key={e} type="button" onMouseDown={(ev) => ev.preventDefault()} onClick={() => { insertHtml(e); emojiMenu.close(); }}>{e}</button>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover anchor={colorMenu.anchor} onClose={colorMenu.close} side="top" closeOnClick={false} width={230}>
|
||||
<div className="color-grid">
|
||||
{COLORS.map((c) => <button key={c} type="button" style={{ background: c }} onMouseDown={(ev) => ev.preventDefault()} onClick={() => { exec("foreColor", c); colorMenu.close(); }} aria-label={c} />)}
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover anchor={hiliteMenu.anchor} onClose={hiliteMenu.close} side="top" closeOnClick={false} width={230}>
|
||||
<div className="color-grid">
|
||||
{COLORS.map((c) => <button key={c} type="button" style={{ background: c }} onMouseDown={(ev) => ev.preventDefault()} onClick={() => { exec("hiliteColor", c); hiliteMenu.close(); }} aria-label={c} />)}
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover anchor={linkMenu.anchor} onClose={linkMenu.close} side="top" closeOnClick={false} width={320}>
|
||||
<form className="link-popup" onSubmit={(e) => { e.preventDefault(); applyLink(); }}>
|
||||
<input className="input sm" autoFocus placeholder="https://…" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
|
||||
<button type="submit" className="btn btn-sm btn-primary">Link</button>
|
||||
</form>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, Trash2, Camera, X } from "lucide-react";
|
||||
import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } from "@/jmap/types";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { buildName, contactDisplayName, nameParts, newKey } from "@/lib/contacts";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { client } from "@/jmap/client";
|
||||
|
||||
interface Props {
|
||||
card: Partial<ContactCard>;
|
||||
defaultBookId: string | null;
|
||||
onClose: () => void;
|
||||
onSaved: (id: string) => void;
|
||||
}
|
||||
|
||||
const EMAIL_CTX = ["private", "work", "other"];
|
||||
const PHONE_CTX = ["mobile", "private", "work", "fax", "other"];
|
||||
const ADDR_CTX = ["private", "work", "other"];
|
||||
|
||||
type EmailRow = { key: string; address: string; ctx: string };
|
||||
type PhoneRow = { key: string; number: string; ctx: string };
|
||||
type AddrRow = { key: string; ctx: string; street: string; city: string; region: string; postcode: string; country: string };
|
||||
|
||||
export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props) {
|
||||
const contacts = useContacts();
|
||||
const isNew = !card.id;
|
||||
const np = card.id ? nameParts(card as ContactCard) : { given: "", surname: "", middle: "", prefix: "", suffix: "" };
|
||||
const [kind, setKind] = useState<"individual" | "group" | "org">((card.kind as "individual" | "group" | "org") ?? "individual");
|
||||
const [given, setGiven] = useState(np.given);
|
||||
const [surname, setSurname] = useState(np.surname);
|
||||
const [prefix, setPrefix] = useState(np.prefix);
|
||||
const [middle, setMiddle] = useState(np.middle);
|
||||
const [suffix, setSuffix] = useState(np.suffix);
|
||||
const [nickname, setNickname] = useState(Object.values(card.nicknames ?? {})[0]?.name ?? "");
|
||||
const [company, setCompany] = useState(Object.values(card.organizations ?? {})[0]?.name ?? "");
|
||||
const [jobTitle, setJobTitle] = useState(Object.values(card.titles ?? {})[0]?.name ?? "");
|
||||
const [emails, setEmails] = useState<EmailRow[]>(() => Object.entries(card.emails ?? {}).map(([key, e]) => ({ key, address: e.address, ctx: Object.keys(e.contexts ?? {})[0] ?? "other" })));
|
||||
const [phones, setPhones] = useState<PhoneRow[]>(() => Object.entries(card.phones ?? {}).map(([key, p]) => ({ key, number: p.number, ctx: Object.keys(p.features ?? {})[0] ?? Object.keys(p.contexts ?? {})[0] ?? "other" })));
|
||||
const [addrs, setAddrs] = useState<AddrRow[]>(() => Object.entries(card.addresses ?? {}).map(([key, a]) => {
|
||||
const get = (k: string) => (a.components ?? []).filter((c) => c.kind === k).map((c) => c.value).join(" ");
|
||||
return { key, ctx: Object.keys(a.contexts ?? {})[0] ?? "other", street: [get("number"), get("name"), get("apartment")].filter(Boolean).join(" ") || (a.full ?? ""), city: get("locality"), region: get("region"), postcode: get("postcode"), country: get("country") };
|
||||
}));
|
||||
const [birthday, setBirthday] = useState(() => {
|
||||
const b = Object.values(card.anniversaries ?? {}).find((a) => a.kind === "birth")?.date;
|
||||
return b?.year && b.month && b.day ? `${b.year}-${String(b.month).padStart(2, "0")}-${String(b.day).padStart(2, "0")}` : "";
|
||||
});
|
||||
const [website, setWebsite] = useState(Object.values(card.links ?? {})[0]?.uri ?? "");
|
||||
const [note, setNote] = useState(Object.values(card.notes ?? {})[0]?.note ?? "");
|
||||
const [bookId, setBookId] = useState(Object.keys(card.addressBookIds ?? {})[0] ?? defaultBookId ?? "");
|
||||
const [photo, setPhoto] = useState<{ dataUrl: string; type: string } | null>(null);
|
||||
const [removePhoto, setRemovePhoto] = useState(false);
|
||||
const [memberUids, setMemberUids] = useState<string[]>(Object.keys(card.members ?? {}));
|
||||
const [memberQuery, setMemberQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const books = Object.values(contacts.books);
|
||||
const existingPhoto = card.id && contacts.accountId ? Object.values(card.media ?? {}).find((m) => m.kind === "photo") : undefined;
|
||||
|
||||
const memberCandidates = useMemo(() => {
|
||||
if (!memberQuery.trim()) return [];
|
||||
return contacts.search(memberQuery).filter((c) => c.kind !== "group" && !memberUids.includes(c.uid)).slice(0, 6);
|
||||
}, [memberQuery, contacts, memberUids]);
|
||||
|
||||
const save = async () => {
|
||||
if (!bookId) {
|
||||
toast.error("Choose an address book");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const obj: Record<string, unknown> = {};
|
||||
obj.kind = kind;
|
||||
const name = buildName({ given, surname, middle, prefix, suffix });
|
||||
if (kind === "individual") obj.name = name ?? null;
|
||||
else {
|
||||
obj.name = company ? { "@type": "Name", full: company } : (name ?? null);
|
||||
}
|
||||
obj.nicknames = nickname ? { [newKey("n")]: { "@type": "Nickname", name: nickname } } : null;
|
||||
obj.organizations = company ? { [newKey("o")]: { "@type": "Organization", name: company } } : null;
|
||||
obj.titles = jobTitle ? { [newKey("t")]: { "@type": "Title", name: jobTitle, kind: "title" } } : null;
|
||||
const em: Record<string, JSContactEmail> = {};
|
||||
emails.filter((e) => e.address.trim()).forEach((e, i) => { em[e.key] = { "@type": "EmailAddress", address: e.address.trim(), contexts: e.ctx !== "other" ? { [e.ctx]: true } : undefined, pref: i === 0 ? 1 : undefined }; });
|
||||
obj.emails = Object.keys(em).length ? em : null;
|
||||
const ph: Record<string, JSContactPhone> = {};
|
||||
phones.filter((p) => p.number.trim()).forEach((p) => { ph[p.key] = { "@type": "Phone", number: p.number.trim(), ...(["mobile", "fax"].includes(p.ctx) ? { features: { [p.ctx === "mobile" ? "mobile" : "fax"]: true } } : p.ctx !== "other" ? { contexts: { [p.ctx]: true } } : {}) }; });
|
||||
obj.phones = Object.keys(ph).length ? ph : null;
|
||||
const ad: Record<string, JSContactAddress> = {};
|
||||
addrs.filter((a) => a.street || a.city || a.country || a.postcode).forEach((a) => {
|
||||
const components: JSContactAddress["components"] = [];
|
||||
if (a.street) components.push({ "@type": "AddressComponent", kind: "name", value: a.street });
|
||||
if (a.city) components.push({ "@type": "AddressComponent", kind: "locality", value: a.city });
|
||||
if (a.region) components.push({ "@type": "AddressComponent", kind: "region", value: a.region });
|
||||
if (a.postcode) components.push({ "@type": "AddressComponent", kind: "postcode", value: a.postcode });
|
||||
if (a.country) components.push({ "@type": "AddressComponent", kind: "country", value: a.country });
|
||||
ad[a.key] = { "@type": "Address", components, contexts: a.ctx !== "other" ? { [a.ctx]: true } : undefined };
|
||||
});
|
||||
obj.addresses = Object.keys(ad).length ? ad : null;
|
||||
if (birthday) {
|
||||
const [y, m, d] = birthday.split("-").map(Number) as [number, number, number];
|
||||
obj.anniversaries = { [newKey("a")]: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", year: y, month: m, day: d } } };
|
||||
} else obj.anniversaries = null;
|
||||
obj.links = website ? { [newKey("l")]: { "@type": "Link", uri: /^https?:/i.test(website) ? website : `https://${website}` } } : null;
|
||||
obj.notes = note.trim() ? { [newKey("x")]: { "@type": "Note", note: note.trim() } } : null;
|
||||
obj.members = kind === "group" && memberUids.length ? Object.fromEntries(memberUids.map((u) => [u, true])) : null;
|
||||
if (photo) {
|
||||
const m = /^data:([^;]+);base64,(.*)$/s.exec(photo.dataUrl);
|
||||
if (m) {
|
||||
const bin = atob(m[2]!);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
const up = await client.upload(contacts.accountId!, new Blob([bytes], { type: m[1]! }), { type: m[1]! });
|
||||
obj.media = { [newKey("p")]: { "@type": "Media", kind: "photo", blobId: up.blobId, mediaType: m[1]! } };
|
||||
}
|
||||
} else if (removePhoto) obj.media = null;
|
||||
if (isNew) {
|
||||
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
|
||||
toast.success("Contact created");
|
||||
onSaved(id);
|
||||
} else {
|
||||
const patch: Record<string, unknown> = { ...obj };
|
||||
const curBook = Object.keys(card.addressBookIds ?? {})[0];
|
||||
if (curBook !== bookId) patch.addressBookIds = { [bookId]: true };
|
||||
if (!photo && !removePhoto) delete patch.media;
|
||||
await contacts.updateCard(card.id!, patch);
|
||||
toast.success("Contact saved");
|
||||
onSaved(card.id!);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPhoto = (f: File) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(f);
|
||||
img.onload = () => {
|
||||
const size = 256;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = size;
|
||||
c.height = size;
|
||||
const ctx = c.getContext("2d")!;
|
||||
const s = Math.min(img.width, img.height);
|
||||
ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);
|
||||
setPhoto({ dataUrl: c.toDataURL("image/jpeg", 0.85), type: "image/jpeg" });
|
||||
setRemovePhoto(false);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const photoSrc = photo?.dataUrl ?? (!removePhoto && existingPhoto ? (existingPhoto.uri?.startsWith("data:") ? existingPhoto.uri : existingPhoto.blobId ? client.downloadUrl(contacts.accountId!, existingPhoto.blobId, "photo", existingPhoto.mediaType ?? "image/jpeg", true) : null) : null);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={isNew ? "New contact" : `Edit ${contactDisplayName(card as ContactCard)}`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
|
||||
<div className="contact-form">
|
||||
<div className="row" style={{ gap: 16, marginBottom: 12 }}>
|
||||
<label className="avatar xl" style={{ background: "var(--bg-sunken)", color: "var(--fg-muted)", cursor: "pointer", position: "relative" }} title="Change photo">
|
||||
{photoSrc ? <img src={photoSrc} alt="" /> : <Camera size={28} />}
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onPhoto(f); e.target.value = ""; }} />
|
||||
</label>
|
||||
{photoSrc && <button className="btn btn-ghost btn-sm" onClick={() => { setPhoto(null); setRemovePhoto(true); }}><X size={14} /> Remove photo</button>}
|
||||
<span className="spacer" />
|
||||
<div className="field" style={{ marginBottom: 0, width: 160 }}>
|
||||
<label>Type</label>
|
||||
<select className="select" value={kind} onChange={(e) => setKind(e.target.value as typeof kind)}>
|
||||
<option value="individual">Person</option>
|
||||
<option value="org">Organization</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0, width: 200 }}>
|
||||
<label>Address book</label>
|
||||
<select className="select" value={bookId} onChange={(e) => setBookId(e.target.value)}>
|
||||
{books.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{kind === "individual" ? (
|
||||
<>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>First name</label><input className="input" value={given} onChange={(e) => setGiven(e.target.value)} autoFocus /></div>
|
||||
<div className="field"><label>Last name</label><input className="input" value={surname} onChange={(e) => setSurname(e.target.value)} /></div>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="hint" style={{ cursor: "pointer", marginBottom: 8 }}>More name fields</summary>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Prefix</label><input className="input" value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder="Dr." /></div>
|
||||
<div className="field"><label>Middle name</label><input className="input" value={middle} onChange={(e) => setMiddle(e.target.value)} /></div>
|
||||
<div className="field"><label>Suffix</label><input className="input" value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder="Jr." /></div>
|
||||
<div className="field"><label>Nickname</label><input className="input" value={nickname} onChange={(e) => setNickname(e.target.value)} /></div>
|
||||
</div>
|
||||
</details>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Company</label><input className="input" value={company} onChange={(e) => setCompany(e.target.value)} /></div>
|
||||
<div className="field"><label>Job title</label><input className="input" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} /></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="field"><label>{kind === "group" ? "Group name" : "Organization name"}</label><input className="input" value={company} onChange={(e) => setCompany(e.target.value)} autoFocus /></div>
|
||||
)}
|
||||
|
||||
{kind === "group" && (
|
||||
<div className="field">
|
||||
<label>Members</label>
|
||||
<div className="row wrap gap-4 mb-8">
|
||||
{memberUids.map((uid) => {
|
||||
const m = Object.values(contacts.cards).find((x) => x.uid === uid);
|
||||
return <span key={uid} className="chip">{m ? contactDisplayName(m) : uid}<button className="chip-x" onClick={() => setMemberUids(memberUids.filter((u) => u !== uid))}><X size={12} /></button></span>;
|
||||
})}
|
||||
</div>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input className="input" placeholder="Search contacts to add…" value={memberQuery} onChange={(e) => setMemberQuery(e.target.value)} />
|
||||
{memberCandidates.length > 0 && (
|
||||
<div className="suggest-list" style={{ width: "100%" }}>
|
||||
{memberCandidates.map((c) => <div key={c.id} className="suggest-item" onMouseDown={(e) => { e.preventDefault(); setMemberUids([...memberUids, c.uid]); setMemberQuery(""); }}><span className="s-name">{contactDisplayName(c)}</span><span className="s-email">{Object.values(c.emails ?? {})[0]?.address}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Email</label>
|
||||
<div className="multi">
|
||||
{emails.map((e, i) => (
|
||||
<div key={e.key} className="multi-row">
|
||||
<input className="input" type="email" value={e.address} placeholder="[email protected]" onChange={(ev) => setEmails(emails.map((x, j) => (j === i ? { ...x, address: ev.target.value } : x)))} />
|
||||
<select className="select" value={e.ctx} onChange={(ev) => setEmails(emails.map((x, j) => (j === i ? { ...x, ctx: ev.target.value } : x)))}>{EMAIL_CTX.map((c) => <option key={c} value={c}>{c}</option>)}</select>
|
||||
<button className="icon-btn sm danger" onClick={() => setEmails(emails.filter((_, j) => j !== i))} aria-label="Remove"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setEmails([...emails, { key: newKey("e"), address: "", ctx: emails.length ? "work" : "private" }])}><Plus size={14} /> Add email</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Phone</label>
|
||||
<div className="multi">
|
||||
{phones.map((p, i) => (
|
||||
<div key={p.key} className="multi-row">
|
||||
<input className="input" type="tel" value={p.number} placeholder="+1 555 0100" onChange={(ev) => setPhones(phones.map((x, j) => (j === i ? { ...x, number: ev.target.value } : x)))} />
|
||||
<select className="select" value={p.ctx} onChange={(ev) => setPhones(phones.map((x, j) => (j === i ? { ...x, ctx: ev.target.value } : x)))}>{PHONE_CTX.map((c) => <option key={c} value={c}>{c}</option>)}</select>
|
||||
<button className="icon-btn sm danger" onClick={() => setPhones(phones.filter((_, j) => j !== i))} aria-label="Remove"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setPhones([...phones, { key: newKey("p"), number: "", ctx: "mobile" }])}><Plus size={14} /> Add phone</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Address</label>
|
||||
<div className="multi">
|
||||
{addrs.map((a, i) => (
|
||||
<div key={a.key} className="card" style={{ marginBottom: 0 }}>
|
||||
<div className="row mb-8">
|
||||
<select className="select" style={{ width: 140 }} value={a.ctx} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, ctx: ev.target.value } : x)))}>{ADDR_CTX.map((c) => <option key={c} value={c}>{c}</option>)}</select>
|
||||
<span className="spacer" />
|
||||
<button className="icon-btn sm danger" onClick={() => setAddrs(addrs.filter((_, j) => j !== i))} aria-label="Remove"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div className="addr-grid">
|
||||
<input className="input" style={{ gridColumn: "1 / -1" }} placeholder="Street" value={a.street} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, street: ev.target.value } : x)))} />
|
||||
<input className="input" placeholder="City" value={a.city} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, city: ev.target.value } : x)))} />
|
||||
<input className="input" placeholder="State / Region" value={a.region} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, region: ev.target.value } : x)))} />
|
||||
<input className="input" placeholder="Postal code" value={a.postcode} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, postcode: ev.target.value } : x)))} />
|
||||
<input className="input" placeholder="Country" value={a.country} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, country: ev.target.value } : x)))} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAddrs([...addrs, { key: newKey("a"), ctx: "private", street: "", city: "", region: "", postcode: "", country: "" }])}><Plus size={14} /> Add address</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Birthday</label><input className="input" type="date" value={birthday} onChange={(e) => setBirthday(e.target.value)} /></div>
|
||||
<div className="field"><label>Website</label><input className="input" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="https://" /></div>
|
||||
</div>
|
||||
<div className="field"><label>Notes</label><textarea className="textarea" value={note} onChange={(e) => setNote(e.target.value)} /></div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ArrowLeft, Book, Download, Mail, MoreVertical, Pencil, Plus, Search, Share2, Trash2, Upload, Users, Phone, MapPin, Building2, Cake, StickyNote, Globe, Calendar as CalIcon, Star, Pin } from "lucide-react";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import type { AddressBook, ContactCard } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts";
|
||||
import { Avatar, Empty, Spinner, useIsNarrow } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ContactEditor } from "./ContactEditor";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { avatarColor } from "@/lib/address";
|
||||
|
||||
export function ContactsView({ id }: { id?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const contacts = useContacts();
|
||||
const narrow = useIsNarrow();
|
||||
const [q, setQ] = useState("");
|
||||
const [bookId, setBookId] = useState<string | "all">("all");
|
||||
const [editing, setEditing] = useState<Partial<ContactCard> | null>(null);
|
||||
const [share, setShare] = useState<AddressBook | null>(null);
|
||||
const bookMenu = useMenu();
|
||||
const [menuBook, setMenuBook] = useState<AddressBook | null>(null);
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
useEffect(() => {
|
||||
if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contacts.available, contacts.loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const onNew = () => setEditing({});
|
||||
window.addEventListener("ihm:new-contact", onNew);
|
||||
return () => window.removeEventListener("ihm:new-contact", onNew);
|
||||
}, []);
|
||||
|
||||
const list = useMemo(() => {
|
||||
const all = contacts.search(q);
|
||||
return bookId === "all" ? all : all.filter((c) => c.addressBookIds?.[bookId]);
|
||||
}, [contacts, q, bookId]);
|
||||
|
||||
const selected = id ? contacts.cards[id] : undefined;
|
||||
const books = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
const groups = useMemo(() => {
|
||||
const out: Array<{ letter: string; items: ContactCard[] }> = [];
|
||||
for (const c of list) {
|
||||
const letter = (sortKey(c)[0] ?? "#").toUpperCase();
|
||||
const key = /[A-Z]/.test(letter) ? letter : "#";
|
||||
const g = out[out.length - 1];
|
||||
if (g && g.letter === key) g.items.push(c);
|
||||
else out.push({ letter: key, items: [c] });
|
||||
}
|
||||
return out;
|
||||
}, [list]);
|
||||
|
||||
if (!contacts.available) {
|
||||
return <div className="p-16"><Empty icon={<Users size={40} />} title="Contacts are not available">This account does not have the JMAP contacts capability.</Empty></div>;
|
||||
}
|
||||
|
||||
const exportAll = () => {
|
||||
const text = list.map(toVCard).join("");
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: "text/vcard" }));
|
||||
a.download = "contacts.vcf";
|
||||
a.click();
|
||||
};
|
||||
|
||||
const importFile = async (f: File) => {
|
||||
const book = bookId !== "all" ? contacts.books[bookId] : (books.find((b) => b.isDefault) ?? books[0]);
|
||||
if (!book) {
|
||||
toast.error("Create an address book first");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const n = await contacts.importVCard(await f.text(), book.id);
|
||||
toast.success(`Imported ${n} contact${n === 1 ? "" : "s"}`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`contacts-layout ${selected || editing ? "detail" : ""}`}>
|
||||
<aside className="contacts-books">
|
||||
<button className={`nav-item ${bookId === "all" ? "active" : ""}`} style={{ width: "100%" }} onClick={() => setBookId("all")}>
|
||||
<Users size={18} /><span className="nav-label">All contacts</span><span className="nav-count">{Object.keys(contacts.cards).length}</span>
|
||||
</button>
|
||||
<div className="nav-section"><span>Address books</span>
|
||||
<button className="icon-btn" title="New address book" onClick={async () => { const n = await promptDialog({ title: "New address book", placeholder: "Name" }); if (n?.trim()) { try { await contacts.createBook(n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><Plus size={16} /></button>
|
||||
</div>
|
||||
{books.map((b) => (
|
||||
<button key={b.id} className={`nav-item ${bookId === b.id ? "active" : ""}`} style={{ width: "100%" }} onClick={() => setBookId(b.id)} onContextMenu={(e) => { e.preventDefault(); setMenuBook(b); bookMenu.openAt(e.clientX, e.clientY); }}>
|
||||
<Book size={18} /><span className="nav-label">{b.name}</span>
|
||||
<span className="icon-btn nav-more" onClick={(e) => { e.stopPropagation(); setMenuBook(b); bookMenu.open(e); }}><MoreVertical size={16} /></span>
|
||||
</button>
|
||||
))}
|
||||
<div style={{ padding: "12px 8px" }} className="col gap-8">
|
||||
<label className="btn btn-sm btn-block"><Upload size={14} /> Import vCard<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void importFile(f); e.target.value = ""; }} /></label>
|
||||
<button className="btn btn-sm btn-block" onClick={exportAll}><Download size={14} /> Export {bookId === "all" ? "all" : "book"}</button>
|
||||
</div>
|
||||
<Popover anchor={bookMenu.anchor} onClose={bookMenu.close} width={220}>
|
||||
{menuBook && (
|
||||
<>
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name }); if (n?.trim()) void contacts.updateBook(menuBook.id, { name: n.trim() }).catch((err) => toast.error((err as Error).message)); }} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuBook)} />
|
||||
<MenuItem icon={<Star size={16} />} label={menuBook.isDefault ? "Default book" : "Make default"} disabled={menuBook.isDefault} onClick={() => void contacts.updateBook(menuBook.id, { isDefault: true } as Partial<AddressBook>).catch((err) => toast.error((err as Error).message))} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuBook.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "All contacts in it will be deleted.", confirmLabel: "Delete", danger: true })) void contacts.destroyBook(menuBook.id).catch((err) => toast.error((err as Error).message)); }} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</aside>
|
||||
|
||||
<section className="contacts-list">
|
||||
<div className="list-search row">
|
||||
<div className="search-input" style={{ flex: 1, height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
|
||||
<Search size={16} className="muted" />
|
||||
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder="Search contacts" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<button className="icon-btn" title="New contact" onClick={() => setEditing({})}><Plus size={20} /></button>
|
||||
</div>
|
||||
<div className="contacts-scroll">
|
||||
{contacts.loading && !contacts.loaded ? <Spinner label="Loading contacts…" /> : !list.length ? (
|
||||
<Empty icon={<Users size={36} />} title={q ? "No matches" : "No contacts yet"}>{q ? "Try another search." : "Add a contact or import a vCard file."}</Empty>
|
||||
) : groups.map((g) => (
|
||||
<div key={g.letter}>
|
||||
<div className="contact-letter">{g.letter}</div>
|
||||
{g.items.map((c) => {
|
||||
const email = contactEmails(c)[0]?.email;
|
||||
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
|
||||
return (
|
||||
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
|
||||
<span className="avatar" style={{ background: photo ? "transparent" : avatarColor(email ?? contactDisplayName(c)) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={16} /> : contactDisplayName(c).slice(0, 1).toUpperCase()}</span>
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="c-name">{contactDisplayName(c)}{c.kind === "group" ? <span className="hint"> · group</span> : ""}</div>
|
||||
<div className="c-email">{email ?? Object.values(c.phones ?? {})[0]?.number ?? Object.values(c.organizations ?? {})[0]?.name ?? ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="contact-detail">
|
||||
{selected ? (
|
||||
<ContactDetail card={selected} onBack={() => navigate("/contacts")} onEdit={() => setEditing(selected)} narrow={narrow} onEmail={(addr) => openCompose({ to: [{ name: contactDisplayName(selected), email: addr }] })} />
|
||||
) : (
|
||||
<div className="no-thread"><Users size={48} style={{ color: "var(--fg-faint)" }} /><div>Select a contact</div></div>
|
||||
)}
|
||||
</section>
|
||||
{editing && <ContactEditor card={editing} defaultBookId={bookId !== "all" ? bookId : (books.find((b) => b.isDefault)?.id ?? books[0]?.id ?? null)} onClose={() => setEditing(null)} onSaved={(cid) => { setEditing(null); navigate(`/contacts/${cid}`); }} />}
|
||||
{share && <ShareDialog kind="AddressBook" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: ContactCard; onBack: () => void; onEdit: () => void; narrow: boolean; onEmail: (addr: string) => void }) {
|
||||
const contacts = useContacts();
|
||||
const [, navigate] = useLocation();
|
||||
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
|
||||
const name = contactDisplayName(c);
|
||||
const org = Object.values(c.organizations ?? {})[0];
|
||||
const title = Object.values(c.titles ?? {})[0];
|
||||
const books = Object.keys(c.addressBookIds ?? {}).map((id) => contacts.books[id]?.name).filter(Boolean);
|
||||
const members = c.kind === "group" ? Object.keys(c.members ?? {}).map((uid) => Object.values(contacts.cards).find((x) => x.uid === uid)).filter((x): x is ContactCard => Boolean(x)) : [];
|
||||
const ctxLabel = (ctx?: Record<string, boolean>, label?: string) => label || Object.keys(ctx ?? {}).join(", ") || "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
{narrow && <button className="icon-btn" onClick={onBack} aria-label="Back"><ArrowLeft size={20} /></button>}
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> Edit</button>
|
||||
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> vCard</button>
|
||||
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: `Delete ${name}?`, confirmLabel: "Delete", danger: true })) { try { await contacts.destroyCards([c.id]); toast.success("Contact deleted"); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
<div className="contact-hero">
|
||||
<span className="avatar xl" style={{ background: photo ? "transparent" : avatarColor(contactEmails(c)[0]?.email ?? name) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={36} /> : name.slice(0, 1).toUpperCase()}</span>
|
||||
<div>
|
||||
<h1>{name}</h1>
|
||||
{(title?.name || org?.name) && <div className="sub">{[title?.name, org?.name].filter(Boolean).join(" · ")}</div>}
|
||||
{Object.values(c.nicknames ?? {})[0]?.name && <div className="sub">“{Object.values(c.nicknames ?? {})[0]!.name}”</div>}
|
||||
{books.length > 0 && <div className="hint">{books.join(", ")}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{Object.values(c.emails ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Email</h3>
|
||||
{Object.values(c.emails ?? {}).map((e, i) => (
|
||||
<div key={i} className="contact-kv"><span className="k">{ctxLabel(e.contexts, e.label) || "email"}</span><span className="v row gap-8"><a href={`mailto:${e.address}`} onClick={(ev) => { ev.preventDefault(); onEmail(e.address); }}>{e.address}</a><button className="icon-btn xs" title="Compose" onClick={() => onEmail(e.address)}><Mail size={14} /></button></span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(c.phones ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Phone</h3>
|
||||
{Object.values(c.phones ?? {}).map((p, i) => (
|
||||
<div key={i} className="contact-kv"><span className="k">{ctxLabel({ ...p.contexts, ...p.features }, p.label) || "phone"}</span><span className="v row gap-8"><Phone size={14} className="muted" /><a href={`tel:${p.number}`}>{p.number}</a></span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(c.addresses ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Address</h3>
|
||||
{Object.values(c.addresses ?? {}).map((a, i) => (
|
||||
<div key={i} className="contact-kv"><span className="k">{ctxLabel(a.contexts) || "address"}</span><span className="v row gap-8" style={{ alignItems: "flex-start" }}><MapPin size={14} className="muted" style={{ marginTop: 3 }} /><span>{formatAddressLines(a).map((l, j) => <div key={j}>{l}</div>)}</span></span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(org || Object.values(c.titles ?? {}).length > 1) && (
|
||||
<div className="contact-section"><h3>Work</h3>
|
||||
{org?.name && <div className="contact-kv"><span className="k">Company</span><span className="v row gap-8"><Building2 size={14} className="muted" />{org.name}{org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}</span></div>}
|
||||
{Object.values(c.titles ?? {}).map((t, i) => <div key={i} className="contact-kv"><span className="k">{t.kind === "role" ? "Role" : "Title"}</span><span className="v">{t.name}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(c.anniversaries ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Dates</h3>
|
||||
{Object.values(c.anniversaries ?? {}).map((a, i) => <div key={i} className="contact-kv"><span className="k">{a.kind === "birth" ? "Birthday" : a.kind === "wedding" ? "Anniversary" : a.kind}</span><span className="v row gap-8"><Cake size={14} className="muted" />{fmtPartial(a.date)}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
{(Object.values(c.links ?? {}).length > 0 || Object.values(c.onlineServices ?? {}).length > 0) && (
|
||||
<div className="contact-section"><h3>Online</h3>
|
||||
{Object.values(c.links ?? {}).map((l, i) => <div key={`l${i}`} className="contact-kv"><span className="k">{l.label ?? "Website"}</span><span className="v row gap-8"><Globe size={14} className="muted" /><a href={l.uri} target="_blank" rel="noreferrer">{l.uri}</a></span></div>)}
|
||||
{Object.values(c.onlineServices ?? {}).map((s, i) => <div key={`s${i}`} className="contact-kv"><span className="k">{s.service ?? s.label ?? "IM"}</span><span className="v">{s.user ?? s.uri}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(c.notes ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Notes</h3>
|
||||
{Object.values(c.notes ?? {}).map((n, i) => <div key={i} className="contact-kv"><span className="k"><StickyNote size={14} /></span><span className="v" style={{ whiteSpace: "pre-wrap" }}>{n.note}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
{c.kind === "group" && (
|
||||
<div className="contact-section"><h3>Members ({Object.keys(c.members ?? {}).length})</h3>
|
||||
{members.map((m) => <div key={m.id} className="contact-kv"><span className="k"><Avatar who={{ name: contactDisplayName(m), email: contactEmails(m)[0]?.email }} size="sm" /></span><span className="v"><a href={`/contacts/${m.id}`} onClick={(e) => { e.preventDefault(); navigate(`/contacts/${m.id}`); }}>{contactDisplayName(m)}</a> <span className="hint">{contactEmails(m)[0]?.email}</span></span></div>)}
|
||||
{members.length > 0 && <button className="btn btn-sm mt-8" onClick={() => useCompose.getState().open({ to: members.flatMap((m) => contactEmails(m).slice(0, 1)) })}><Mail size={14} /> Email group</button>}
|
||||
</div>
|
||||
)}
|
||||
{c.keywords && Object.keys(c.keywords).length > 0 && <div className="row wrap gap-4 mt-8">{Object.keys(c.keywords).map((k) => <span key={k} className="chip"><Pin size={12} /> {k}</span>)}</div>}
|
||||
{c.updated && <p className="hint mt-16"><CalIcon size={12} /> Updated {new Date(c.updated).toLocaleDateString()}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtPartial(d: { year?: number; month?: number; day?: number; utc?: string }): string {
|
||||
if (d.utc) return new Date(d.utc).toLocaleDateString();
|
||||
if (d.year && d.month && d.day) return new Date(d.year, d.month - 1, d.day).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||
if (d.month && d.day) return new Date(2000, d.month - 1, d.day).toLocaleDateString(undefined, { month: "long", day: "numeric" });
|
||||
return [d.year, d.month, d.day].filter(Boolean).join("-");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Trash2, Upload, FolderInput } from "lucide-react";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { client } from "@/jmap/client";
|
||||
import type { FileNode } from "@/jmap/types";
|
||||
import { formatSize, formatListDate } from "@/lib/format";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const files = useFiles();
|
||||
const parentId = nodeId ?? null;
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const menu = useMenu();
|
||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.available) void files.loadChildren(parentId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [files.available, parentId]);
|
||||
|
||||
// Ensure ancestors are loaded for breadcrumbs
|
||||
useEffect(() => {
|
||||
if (!files.available || !parentId) return;
|
||||
const n = files.nodes[parentId];
|
||||
if (!n) {
|
||||
void client.call<{ list: FileNode[] }>("FileNode/get", { accountId: files.accountId, ids: [parentId], fetchParents: true }).then((r) => {
|
||||
useFiles.setState((s) => {
|
||||
const nodes = { ...s.nodes };
|
||||
for (const x of r.list) nodes[x.id] = x;
|
||||
return { nodes };
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentId, files.available]);
|
||||
|
||||
if (!files.available) return <div className="p-16"><Empty icon={<FolderOpen size={40} />} title="File storage is not available">This account does not have the JMAP file storage capability.</Empty></div>;
|
||||
|
||||
const ids = files.children[parentId ?? "root"] ?? [];
|
||||
const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||
const path = files.pathTo(parentId);
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDropping(false);
|
||||
const list = Array.from(e.dataTransfer.files);
|
||||
if (list.length) void files.upload(parentId, list);
|
||||
};
|
||||
|
||||
const download = (n: FileNode) => {
|
||||
if (!n.blobId) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = client.downloadUrl(files.accountId!, n.blobId, n.name, n.type ?? "application/octet-stream");
|
||||
a.download = n.name;
|
||||
a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
|
||||
<div className="files-toolbar">
|
||||
<div className="breadcrumb">
|
||||
<button className={path.length ? "" : "current"} onClick={() => navigate("/files")}><Home size={16} /></button>
|
||||
{path.map((n, i) => (
|
||||
<span key={n.id} className="row gap-4">
|
||||
<ChevronRight size={14} className="faint" />
|
||||
<button className={i === path.length - 1 ? "current" : ""} onClick={() => navigate(`/files/${n.id}`)}>{n.name}</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={() => inputRef.current?.click()}><Upload size={16} /> Upload</button>
|
||||
<input ref={inputRef} type="file" multiple hidden onChange={(e) => { const l = Array.from(e.target.files ?? []); if (l.length) void files.upload(parentId, l); e.target.value = ""; }} />
|
||||
<button className="btn btn-sm" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><FolderPlus size={16} /> New folder</button>
|
||||
</div>
|
||||
{files.uploads.length > 0 && (
|
||||
<div className="list-hint" style={{ flexDirection: "column", alignItems: "stretch", gap: 4 }}>
|
||||
{files.uploads.map((u) => <div key={u.id} className="row"><span className="truncate grow">{u.name}</span>{u.error ? <span style={{ color: "var(--danger)" }}>{u.error}</span> : <span>{u.progress}%</span>}</div>)}
|
||||
</div>
|
||||
)}
|
||||
{files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>}
|
||||
<div className="files-scroll">
|
||||
{files.loading && !nodes.length ? <Spinner /> : !nodes.length ? (
|
||||
<Empty icon={<FolderOpen size={40} />} title="This folder is empty">Drag files here or use Upload.</Empty>
|
||||
) : (
|
||||
<table className="files-table">
|
||||
<thead><tr><th>Name</th><th className="hide-mobile">Size</th><th className="hide-mobile">Modified</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id} className={selected === n.id ? "selected" : ""} onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span></div></td>
|
||||
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
|
||||
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
|
||||
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label="Options"><MoreVertical size={16} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={200}>
|
||||
{menuNode && (
|
||||
<>
|
||||
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label="Download" onClick={() => download(menuNode)} />}
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
<MenuItem icon={<FolderInput size={16} />} label="Move to…" onClick={() => setMoveNode(menuNode)} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveDialog({ node, onClose }: { node: FileNode; onClose: () => void }) {
|
||||
const files = useFiles();
|
||||
const [cur, setCur] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
void files.loadChildren(cur);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cur]);
|
||||
const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && n.id !== node.id));
|
||||
const path = files.pathTo(cur);
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={`Move “${node.name}”`} size="sm" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success("Moved"); onClose(); } catch (err) { toast.error((err as Error).message); } }}>Move here</button></>}>
|
||||
<div className="breadcrumb mb-8">
|
||||
<button onClick={() => setCur(null)}><Home size={14} /></button>
|
||||
{path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)}
|
||||
</div>
|
||||
{dirs.map((d) => <button key={d.id} className="menu-item" onClick={() => setCur(d.id)}><Folder size={16} /><span className="grow">{d.name}</span><ChevronRight size={14} /></button>)}
|
||||
{!dirs.length && <p className="hint">No subfolders here.</p>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { ruleFromEmail, applyRuleToMailbox } from "@/lib/sieveApply";
|
||||
import type { SieveRule } from "@/lib/sieve";
|
||||
import { RuleDialog } from "../settings/RuleDialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
|
||||
/** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */
|
||||
export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) {
|
||||
const sieve = useSieve();
|
||||
const mailbox = useMail((s) => (mailboxId ? s.mailboxes[mailboxId] : undefined));
|
||||
const [rule] = useState<SieveRule>(() => ruleFromEmail(email, mailboxId));
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (sieve.available && !sieve.scripts.length && !sieve.loading) await sieve.load();
|
||||
setReady(true);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!sieve.available) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} title="Filters unavailable" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
||||
<p>Sieve filtering is not enabled for this account.</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
|
||||
|
||||
const { rules } = sieve.rules();
|
||||
if (rules === null) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
||||
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings → Filters & rules</b> to edit the script or switch to managed rules.</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RuleDialog
|
||||
rule={rule}
|
||||
title="Filter messages like this"
|
||||
saveLabel="Create filter"
|
||||
applyMailbox={mailbox ? { id: mailbox.id, name: mailbox.name } : null}
|
||||
onClose={onClose}
|
||||
onSave={(r, applyNow) => {
|
||||
onClose();
|
||||
void saveAndApply(r, rules, applyNow && mailbox ? mailbox.id : null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveAndApply(r: SieveRule, existing: SieveRule[], applyMailboxId: Id | null) {
|
||||
const sieve = useSieve.getState();
|
||||
try {
|
||||
await sieve.saveRules([...existing.filter((x) => x.id !== r.id), r]);
|
||||
} catch (err) {
|
||||
toast.error(`Could not save filter: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
if (!applyMailboxId) {
|
||||
toast.success("Filter created — it will run on new mail");
|
||||
return;
|
||||
}
|
||||
const tid = toast.show("Applying filter to existing messages…", { duration: 0 });
|
||||
try {
|
||||
const res = await applyRuleToMailbox(r, applyMailboxId);
|
||||
toast.dismiss(tid);
|
||||
toast.success(`Filter created · applied to ${res.matched} of ${res.scanned} message${res.scanned === 1 ? "" : "s"}${res.skippedActions.length ? ` (skipped: ${res.skippedActions.join("; ")})` : ""}`, { duration: 8000 });
|
||||
} catch (err) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(`Filter saved, but applying it failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Calendar, Check, HelpCircle, MapPin, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types";
|
||||
import { useCalendar, toInstance, myParticipantKeys } from "@/store/calendar";
|
||||
import { formatTimeRange } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart }) {
|
||||
const cal = useCalendar();
|
||||
const [, navigate] = useLocation();
|
||||
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
|
||||
const [existing, setExisting] = useState<CalendarEvent | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cal.available || !part.blobId) return;
|
||||
let cancelled = false;
|
||||
cal
|
||||
.parseIcs(part.blobId)
|
||||
.then(async (evs) => {
|
||||
if (cancelled) return;
|
||||
setEvents(evs);
|
||||
const first = evs[0];
|
||||
if (first?.uid) setExisting(await cal.findByUid(first.uid));
|
||||
})
|
||||
.catch((err) => !cancelled && setError((err as Error).message));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [part.blobId, cal.available]);
|
||||
|
||||
if (!cal.available) return null;
|
||||
if (error) return null;
|
||||
const ev = events?.[0];
|
||||
if (!ev) return null;
|
||||
const method = (ev.method ?? "").toUpperCase();
|
||||
const inst = toInstance({ ...ev, id: "tmp", calendarIds: {} } as CalendarEvent, cal.calendars);
|
||||
const organizer = Object.values(ev.participants ?? {}).find((p) => p.roles?.owner);
|
||||
const location = Object.values(ev.locations ?? {})[0]?.name;
|
||||
const myStatus = existing ? (myParticipantKeys(existing, cal.identities).map((k) => existing.participants?.[k]?.participationStatus)[0] ?? null) : null;
|
||||
const attendees = Object.values(ev.participants ?? {}).filter((p) => p.roles?.attendee);
|
||||
|
||||
const respond = async (status: "accepted" | "tentative" | "declined") => {
|
||||
setBusy(status);
|
||||
try {
|
||||
let target = existing;
|
||||
if (!target) {
|
||||
const calId = Object.values(cal.calendars).find((c) => c.isDefault)?.id ?? Object.keys(cal.calendars)[0];
|
||||
if (!calId) throw new Error("No calendar available");
|
||||
const id = await cal.importEvent(ev, calId);
|
||||
target = await cal.getEvent(id);
|
||||
}
|
||||
if (!target) throw new Error("Could not add the event to your calendar");
|
||||
await cal.rsvp(target.id, status);
|
||||
setExisting(await cal.getEvent(target.id));
|
||||
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const addToCalendar = async () => {
|
||||
setBusy("add");
|
||||
try {
|
||||
const calId = Object.values(cal.calendars).find((c) => c.isDefault)?.id ?? Object.keys(cal.calendars)[0];
|
||||
if (!calId) throw new Error("No calendar available");
|
||||
const id = await cal.importEvent(ev, calId);
|
||||
setExisting(await cal.getEvent(id));
|
||||
toast.success("Added to your calendar");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const title = method === "CANCEL" ? "Cancelled event" : method === "REPLY" ? "Invitation reply" : method === "REQUEST" ? (existing ? "Invitation (in your calendar)" : "Invitation") : "Event";
|
||||
|
||||
return (
|
||||
<div className="invite-card">
|
||||
<div className="row" style={{ alignItems: "flex-start" }}>
|
||||
<Calendar size={20} style={{ color: "var(--accent)", marginTop: 2 }} />
|
||||
<div className="grow">
|
||||
<div className="hint" style={{ marginBottom: 2 }}>{title}{method === "REPLY" && organizer ? "" : ""}</div>
|
||||
<h4>{ev.title || "(untitled event)"}</h4>
|
||||
{inst && <div className="small">{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}</div>}
|
||||
{location && <div className="small muted row gap-4"><MapPin size={13} /> {location}</div>}
|
||||
{organizer && <div className="small muted">Organizer: {organizer.name || organizer.email || Object.values(organizer.sendTo ?? {})[0]?.replace("mailto:", "")}</div>}
|
||||
{attendees.length > 0 && <div className="small muted">{attendees.length} attendee{attendees.length === 1 ? "" : "s"}</div>}
|
||||
{method === "REPLY" && (
|
||||
<div className="small" style={{ marginTop: 4 }}>
|
||||
{attendees.map((a) => <div key={a.email ?? a.name}>{a.name || a.email}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{method !== "REPLY" && method !== "CANCEL" && (
|
||||
<div className="rsvp">
|
||||
{(method === "REQUEST" || attendees.length > 0) ? (
|
||||
<>
|
||||
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("accepted")}><Check size={14} /> {myStatus === "accepted" ? "Accepted" : "Yes"}</button>
|
||||
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("tentative")}><HelpCircle size={14} /> {myStatus === "tentative" ? "Tentative" : "Maybe"}</button>
|
||||
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("declined")}><X size={14} /> {myStatus === "declined" ? "Declined" : "No"}</button>
|
||||
</>
|
||||
) : (
|
||||
!existing && <button className="btn btn-sm" disabled={Boolean(busy)} onClick={() => void addToCalendar()}><Calendar size={14} /> Add to calendar</button>
|
||||
)}
|
||||
{existing && inst && <button className="btn btn-ghost btn-sm" onClick={() => navigate(`/calendar/day/${inst.start.toISOString().slice(0, 10)}`)}>Open in calendar</button>}
|
||||
</div>
|
||||
)}
|
||||
{method === "CANCEL" && existing && (
|
||||
<div className="rsvp">
|
||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="sr-only">{email.id}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { Popover } from "@/ui/popover";
|
||||
import type { Id } from "@/jmap/types";
|
||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||
|
||||
/** Labels are IMAP keywords on the messages; their names/colors live in settings. */
|
||||
export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; anchor: { x: number; y: number }; onClose: () => void; onApplied?: () => void }) {
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
const update = useSettings((s) => s.update);
|
||||
const emails = useMail((s) => s.emails);
|
||||
const setKeyword = useMail((s) => s.setKeyword);
|
||||
const [q, setQ] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const has = (kw: string) => ids.every((id) => emails[id]?.keywords[kw]);
|
||||
const some = (kw: string) => ids.some((id) => emails[id]?.keywords[kw]);
|
||||
const filtered = labels.filter((l) => l.name.toLowerCase().includes(q.toLowerCase()));
|
||||
|
||||
const create = () => {
|
||||
const name = q.trim();
|
||||
if (!name) return;
|
||||
const keyword = name.toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
|
||||
if (labels.some((l) => l.keyword === keyword)) return;
|
||||
const color = CALENDAR_COLORS[labels.length % CALENDAR_COLORS.length]!;
|
||||
update({ labels: [...labels, { keyword, name, color }] });
|
||||
void setKeyword(ids, keyword, true).then(onApplied);
|
||||
setQ("");
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover anchor={{ x: anchor.x, y: anchor.y, w: 0, h: 0 }} onClose={onClose} width={260} closeOnClick={false}>
|
||||
<div className="menu-title">Label as</div>
|
||||
<div className="menu-search">
|
||||
<input
|
||||
className="input sm"
|
||||
autoFocus
|
||||
placeholder={labels.length ? "Search or create label" : "New label name"}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (filtered.length === 1 && !creating) {
|
||||
const l = filtered[0]!;
|
||||
void setKeyword(ids, l.keyword, !has(l.keyword)).then(onApplied);
|
||||
} else create();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{filtered.map((l) => {
|
||||
const all = has(l.keyword);
|
||||
const partial = !all && some(l.keyword);
|
||||
return (
|
||||
<label key={l.keyword} className="menu-item" style={{ cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={all}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = partial;
|
||||
}}
|
||||
onChange={(e) => void setKeyword(ids, l.keyword, e.target.checked).then(onApplied)}
|
||||
style={{ accentColor: l.color }}
|
||||
/>
|
||||
<span className="label-dot" style={{ background: l.color }} />
|
||||
<span className="grow truncate">{l.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) && (
|
||||
<button className="menu-item" onClick={create}>
|
||||
<Plus size={16} />
|
||||
<span>Create “{q.trim()}”</span>
|
||||
</button>
|
||||
)}
|
||||
{!labels.length && !q && <div className="hint" style={{ padding: "4px 10px 8px" }}>Type a name to create your first label.</div>}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { buildFilter, describeFilter, parseQuery } from "@/lib/search";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useIsNarrow } from "@/ui/misc";
|
||||
import { Splitter } from "@/ui/Splitter";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { ThreadView } from "./ThreadView";
|
||||
import { MailboxPicker } from "./MailboxPicker";
|
||||
import { LabelPicker } from "./LabelPicker";
|
||||
import type { Id } from "@/jmap/types";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
||||
const [, navigate] = useLocation();
|
||||
const searchStr = useSearch();
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const mailboxesLoaded = useMail((s) => s.mailboxesLoaded);
|
||||
const inboxId = useMail((s) => s.roleId("inbox"));
|
||||
const query = useMail((s) => s.query);
|
||||
const list = useMail((s) => s.list);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const narrow = useIsNarrow();
|
||||
const [focusId, setFocusId] = useState<Id | null>(null);
|
||||
const [movePicker, setMovePicker] = useState<{ ids: Id[] } | null>(null);
|
||||
const [labelPicker, setLabelPicker] = useState<{ ids: Id[]; anchor: { x: number; y: number } } | null>(null);
|
||||
|
||||
const q = useMemo(() => (search ? (new URLSearchParams(searchStr).get("q") ?? "") : ""), [search, searchStr]);
|
||||
|
||||
// Redirect /mail → inbox
|
||||
useEffect(() => {
|
||||
if (!search && !mailboxId && inboxId) navigate(`/mail/${inboxId}`, { replace: true });
|
||||
}, [search, mailboxId, inboxId, navigate]);
|
||||
|
||||
// Build & run the list query
|
||||
const listQuery = useMemo<ListQuery | null>(() => {
|
||||
if (search) {
|
||||
if (!q) return null;
|
||||
const parsed = parseQuery(q);
|
||||
const filter = buildFilter(parsed, mailboxes, null);
|
||||
const inMb = parsed.in ? (Object.values(mailboxes).find((m) => m.name.toLowerCase() === parsed.in!.toLowerCase())?.id ?? null) : null;
|
||||
return { key: "", filter, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode, mailboxId: inMb, label: describeFilter(parsed) };
|
||||
}
|
||||
if (!mailboxId) return null;
|
||||
const mb = mailboxes[mailboxId];
|
||||
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent";
|
||||
return { key: "", filter: { inMailbox: mailboxId }, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId };
|
||||
}, [search, q, mailboxId, mailboxes, settings.conversationMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listQuery && mailboxesLoaded) void query(listQuery);
|
||||
}, [listQuery, query, mailboxesLoaded]);
|
||||
|
||||
const openThread = useCallback(
|
||||
(tid: Id | null) => {
|
||||
const base = search ? `/search` : `/mail/${mailboxId}`;
|
||||
const qs = search ? `?q=${encodeURIComponent(q)}` : "";
|
||||
navigate(tid ? `${base}/${tid}${qs}` : `${base}${qs}`);
|
||||
},
|
||||
[navigate, search, mailboxId, q],
|
||||
);
|
||||
|
||||
// Row ids in list + helpers for keyboard nav
|
||||
const ids = list?.ids ?? [];
|
||||
const emails = useMail((s) => s.emails);
|
||||
const threads = useMail((s) => s.threads);
|
||||
const selected = useMail((s) => s.selected);
|
||||
|
||||
const rowThreadId = useCallback((rowId: Id) => emails[rowId]?.threadId, [emails]);
|
||||
const currentRowIndex = useMemo(() => {
|
||||
if (focusId) {
|
||||
const i = ids.indexOf(focusId);
|
||||
if (i >= 0) return i;
|
||||
}
|
||||
if (threadId) return ids.findIndex((id) => rowThreadId(id) === threadId);
|
||||
return -1;
|
||||
}, [ids, focusId, threadId, rowThreadId]);
|
||||
|
||||
/** Email ids affected by an action on rows (selection or focused/open row). */
|
||||
const targetIds = useCallback(
|
||||
(rowIds?: Id[]): Id[] => {
|
||||
const rows = rowIds ?? (Object.keys(selected).length ? Object.keys(selected) : focusId ? [focusId] : threadId ? ids.filter((id) => rowThreadId(id) === threadId) : []);
|
||||
const out = new Set<Id>();
|
||||
for (const r of rows) {
|
||||
const e = emails[r];
|
||||
if (!e) continue;
|
||||
if (list?.collapseThreads) {
|
||||
const t = threads[e.threadId];
|
||||
const inScope = t ? t.emailIds.filter((id) => (list.mailboxId ? emails[id]?.mailboxIds[list.mailboxId] : true)) : [r];
|
||||
for (const id of inScope.length ? inScope : [r]) out.add(id);
|
||||
} else out.add(r);
|
||||
}
|
||||
return [...out];
|
||||
},
|
||||
[selected, focusId, threadId, ids, rowThreadId, emails, threads, list],
|
||||
);
|
||||
|
||||
const afterAction = useCallback(
|
||||
(removed: boolean) => {
|
||||
useMail.getState().clearSelection();
|
||||
if (!removed) return;
|
||||
// auto-advance
|
||||
if (threadId) {
|
||||
const idx = currentRowIndex;
|
||||
const adv = settings.autoAdvance;
|
||||
if (adv === "list" || idx < 0) openThread(null);
|
||||
else {
|
||||
const next = adv === "older" ? ids[idx + 1] : ids[idx - 1];
|
||||
const nt = next ? rowThreadId(next) : undefined;
|
||||
if (nt) openThread(nt);
|
||||
else openThread(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[threadId, currentRowIndex, settings.autoAdvance, ids, rowThreadId, openThread],
|
||||
);
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
archive: async (rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (!t.length) return;
|
||||
await useMail.getState().archive(t);
|
||||
afterAction(true);
|
||||
},
|
||||
trash: async (rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (!t.length) return;
|
||||
const mail = useMail.getState();
|
||||
const trashId = mail.roleId("trash");
|
||||
const permanent = t.every((id) => trashId && mail.emails[id]?.mailboxIds[trashId]);
|
||||
if (permanent || settings.confirmDelete) {
|
||||
const ok = await confirmDialog({ title: permanent ? "Delete forever?" : "Delete?", message: permanent ? `${t.length} message(s) will be permanently deleted.` : `Move ${t.length} message(s) to Trash?`, confirmLabel: "Delete", danger: permanent });
|
||||
if (!ok) return;
|
||||
}
|
||||
await mail.trash(t);
|
||||
afterAction(true);
|
||||
},
|
||||
spam: async (rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (!t.length) return;
|
||||
const mail = useMail.getState();
|
||||
const junk = mail.roleId("junk");
|
||||
const inJunk = t.every((id) => junk && mail.emails[id]?.mailboxIds[junk]);
|
||||
await mail.spam(t, !inJunk);
|
||||
afterAction(true);
|
||||
},
|
||||
read: async (read: boolean, rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (t.length) await useMail.getState().markRead(t, read);
|
||||
useMail.getState().clearSelection();
|
||||
},
|
||||
star: async (on: boolean, rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (t.length) await useMail.getState().star(t, on);
|
||||
},
|
||||
move: (rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (t.length) setMovePicker({ ids: t });
|
||||
},
|
||||
label: (rows: Id[] | undefined, anchor: { x: number; y: number }) => {
|
||||
const t = targetIds(rows);
|
||||
if (t.length) setLabelPicker({ ids: t, anchor });
|
||||
},
|
||||
moveTo: async (ids: Id[], mailboxId: Id) => {
|
||||
await useMail.getState().move(ids, mailboxId);
|
||||
afterAction(true);
|
||||
},
|
||||
}),
|
||||
[targetIds, afterAction, settings.confirmDelete],
|
||||
);
|
||||
|
||||
// Keyboard shortcuts for the list/thread
|
||||
const focusRef = useRef(focusId);
|
||||
focusRef.current = focusId;
|
||||
useEffect(() => {
|
||||
const moveFocus = (delta: number) => {
|
||||
const cur = focusRef.current ? ids.indexOf(focusRef.current) : currentRowIndex;
|
||||
const next = Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta));
|
||||
const id = ids[next];
|
||||
if (!id) return;
|
||||
setFocusId(id);
|
||||
if (threadId && settings.readingPane !== "off") {
|
||||
const t = rowThreadId(id);
|
||||
if (t) openThread(t);
|
||||
}
|
||||
document.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
return keyboard.pushScope("mail", [
|
||||
{ keys: "j", description: "Next conversation", group: "Mail", handler: () => moveFocus(1) },
|
||||
{ keys: "k", description: "Previous conversation", group: "Mail", handler: () => moveFocus(-1) },
|
||||
{ keys: "arrowdown", description: "", group: "Mail", handler: () => moveFocus(1) },
|
||||
{ keys: "arrowup", description: "", group: "Mail", handler: () => moveFocus(-1) },
|
||||
{ keys: "o", description: "Open conversation", group: "Mail", handler: () => { const id = focusRef.current; const t = id ? rowThreadId(id) : undefined; if (t) openThread(t); } },
|
||||
{ keys: "enter", description: "", group: "Mail", handler: () => { const id = focusRef.current; const t = id ? rowThreadId(id) : undefined; if (t) { openThread(t); return; } return false; } },
|
||||
{ keys: "u", description: "Back to list", group: "Mail", handler: () => openThread(null) },
|
||||
{ keys: "esc", description: "Back to list / clear selection", group: "Mail", handler: () => { if (Object.keys(useMail.getState().selected).length) useMail.getState().clearSelection(); else openThread(null); } },
|
||||
{ keys: "x", description: "Select conversation", group: "Mail", handler: () => { const id = focusRef.current ?? ids[currentRowIndex]; if (id) useMail.getState().select([id], !useMail.getState().selected[id]); } },
|
||||
{ keys: "e", description: "Archive", group: "Actions", handler: () => void actions.archive() },
|
||||
{ keys: "y", description: "", group: "Actions", handler: () => void actions.archive() },
|
||||
{ keys: "#", description: "Delete", group: "Actions", handler: () => void actions.trash() },
|
||||
{ keys: "delete", description: "", group: "Actions", handler: () => void actions.trash() },
|
||||
{ keys: "!", description: "Report spam / not spam", group: "Actions", handler: () => void actions.spam() },
|
||||
{ keys: "s", description: "Star / unstar", group: "Actions", handler: () => { const t = targetIds(); const on = !t.every((id) => emails[id]?.keywords.$flagged); void actions.star(on); } },
|
||||
{ keys: "shift+i", description: "Mark as read", group: "Actions", handler: () => void actions.read(true) },
|
||||
{ keys: "shift+u", description: "Mark as unread", group: "Actions", handler: () => void actions.read(false) },
|
||||
{ keys: "v", description: "Move to…", group: "Actions", handler: () => actions.move() },
|
||||
{ keys: "l", description: "Label…", group: "Actions", handler: () => actions.label(undefined, { x: window.innerWidth / 2, y: 80 }) },
|
||||
{ keys: "*+a", description: "", group: "Actions", handler: () => useMail.getState().selectAll() },
|
||||
{ keys: "mod+a", description: "Select all", group: "Mail", handler: () => { useMail.getState().selectAll(); } },
|
||||
{ keys: "r", description: "Reply", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "reply" })) },
|
||||
{ keys: "a", description: "Reply all", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "replyAll" })) },
|
||||
{ keys: "f", description: "Forward", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "forward" })) },
|
||||
{ keys: "n", description: "Next message in conversation", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:msg-nav", { detail: 1 })) },
|
||||
{ keys: "p", description: "Previous message in conversation", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:msg-nav", { detail: -1 })) },
|
||||
{ keys: "]", description: "Archive and next", group: "Conversation", handler: () => void actions.archive() },
|
||||
]);
|
||||
}, [ids, currentRowIndex, threadId, settings.readingPane, rowThreadId, openThread, actions, targetIds, emails]);
|
||||
|
||||
const openDraft = useCompose((s) => s.openDraftEmail);
|
||||
const onOpenRow = useCallback(
|
||||
(rowId: Id) => {
|
||||
const e = emails[rowId];
|
||||
if (!e) return;
|
||||
setFocusId(rowId);
|
||||
const mb = mailboxId ? mailboxes[mailboxId] : undefined;
|
||||
if (mb?.role === "drafts" && e.keywords.$draft) {
|
||||
void openDraft(e);
|
||||
return;
|
||||
}
|
||||
openThread(e.threadId);
|
||||
},
|
||||
[emails, mailboxId, mailboxes, openThread, openDraft],
|
||||
);
|
||||
|
||||
const title = search ? `Search: ${listQuery?.label ?? q}` : (mailboxId && mailboxes[mailboxId]?.name) || "Mail";
|
||||
const reading = Boolean(threadId);
|
||||
const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right";
|
||||
const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading);
|
||||
const showReading = settings.readingPane !== "off" || reading;
|
||||
const layoutRef = useRef<HTMLDivElement>(null);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const [liveSize, setLiveSize] = useState<number | null>(null);
|
||||
const paneSize = liveSize ?? (settings.readingPane === "bottom" ? settings.listPaneHeight : settings.listPaneWidth);
|
||||
const onSplit = (delta: number) => {
|
||||
const el = layoutRef.current;
|
||||
const total = el ? (settings.readingPane === "bottom" ? el.clientHeight : el.clientWidth) : 1200;
|
||||
const min = settings.readingPane === "bottom" ? 160 : 320;
|
||||
const max = Math.max(min, total - (settings.readingPane === "bottom" ? 200 : 420));
|
||||
setLiveSize((cur) => Math.min(max, Math.max(min, (cur ?? paneSize) + delta)));
|
||||
};
|
||||
const onSplitEnd = () => {
|
||||
if (liveSize == null) return;
|
||||
updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: liveSize } : { listPaneWidth: liveSize });
|
||||
setLiveSize(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={layoutRef} className={`mail-layout ${paneClass} ${reading ? "reading" : ""}`} style={{ "--list-size": `${paneSize}px` } as React.CSSProperties}>
|
||||
{showList && (
|
||||
<MessageList
|
||||
title={title}
|
||||
list={list}
|
||||
openThreadId={threadId ?? null}
|
||||
focusId={focusId}
|
||||
setFocusId={setFocusId}
|
||||
onOpen={onOpenRow}
|
||||
actions={actions}
|
||||
mailboxId={mailboxId ?? null}
|
||||
isSearch={Boolean(search)}
|
||||
/>
|
||||
)}
|
||||
{showList && showReading && settings.readingPane !== "off" && !narrow && (
|
||||
<Splitter direction={settings.readingPane === "bottom" ? "horizontal" : "vertical"} onResize={onSplit} onEnd={onSplitEnd} onReset={() => updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: 340 } : { listPaneWidth: 520 })} ariaLabel="Resize message list" />
|
||||
)}
|
||||
{showReading && (
|
||||
<div className="mail-reading-pane">
|
||||
{threadId ? (
|
||||
<ThreadView key={threadId} threadId={threadId} mailboxId={mailboxId ?? null} onBack={() => openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} />
|
||||
) : (
|
||||
<div className="no-thread">
|
||||
<img src="/img/logo.png" alt="" />
|
||||
<div>{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}</div>
|
||||
<div className="hint">Select a conversation to read it here · Press <kbd className="kbd">?</kbd> for shortcuts</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{movePicker && (
|
||||
<MailboxPicker
|
||||
title={`Move ${movePicker.ids.length} message${movePicker.ids.length === 1 ? "" : "s"} to…`}
|
||||
onClose={() => setMovePicker(null)}
|
||||
onPick={(mbId) => {
|
||||
setMovePicker(null);
|
||||
void actions.moveTo(movePicker.ids, mbId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{labelPicker && (
|
||||
<LabelPicker
|
||||
ids={labelPicker.ids}
|
||||
anchor={labelPicker.anchor}
|
||||
onClose={() => setLabelPicker(null)}
|
||||
onApplied={() => toast.show("Labels updated")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Folder, Inbox } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
|
||||
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const mailboxPath = useMail((s) => s.mailboxPath);
|
||||
const [q, setQ] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const list = useMemo(() => {
|
||||
const all = Object.values(mailboxes)
|
||||
.filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems)
|
||||
.map((m) => ({ m, path: mailboxPath(m.id) }))
|
||||
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
|
||||
const ql = q.trim().toLowerCase();
|
||||
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
|
||||
}, [mailboxes, mailboxPath, q, exclude]);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={title} size="sm">
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
placeholder="Type a folder name…"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(list.length - 1, a + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const m = list[active]?.m;
|
||||
if (m) onPick(m.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ maxHeight: 360, overflowY: "auto", marginTop: 8 }} role="listbox">
|
||||
{list.map(({ m, path }, i) => (
|
||||
<PickerRow key={m.id} m={m} path={path} active={i === active} onClick={() => onPick(m.id)} onHover={() => setActive(i)} />
|
||||
))}
|
||||
{!list.length && <div className="empty" style={{ padding: 24 }}>No matching folders</div>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerRow({ m, path, active, onClick, onHover }: { m: Mailbox; path: string; active: boolean; onClick: () => void; onHover: () => void }) {
|
||||
return (
|
||||
<button className={`menu-item ${active ? "active" : ""}`} onClick={onClick} onMouseEnter={onHover} role="option" aria-selected={active}>
|
||||
{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}
|
||||
<span className="grow truncate">{path}</span>
|
||||
<span className="menu-kbd">{m.totalEmails}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { AlertOctagon, Archive, ChevronDown, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
|
||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||
inbox: <Inbox size={20} />,
|
||||
drafts: <File size={20} />,
|
||||
sent: <Send size={20} />,
|
||||
trash: <Trash2 size={20} />,
|
||||
junk: <AlertOctagon size={20} />,
|
||||
archive: <Archive size={20} />,
|
||||
all: <Mail size={20} />,
|
||||
flagged: <Star size={20} />,
|
||||
important: <Tag size={20} />,
|
||||
};
|
||||
|
||||
export function MailboxTree() {
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const loaded = useMail((s) => s.mailboxesLoaded);
|
||||
const [location] = useLocation();
|
||||
const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined;
|
||||
const showHidden = useSettings((s) => s.settings.showHiddenFolders);
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
const labelsSidebar = useSettings((s) => s.settings.labelsSidebar);
|
||||
const menu = useMenu();
|
||||
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
||||
const [shareTarget, setShareTarget] = useState<Mailbox | null>(null);
|
||||
|
||||
// Tree: A–Z at every level (Inbox pinned to the top of the root), subfolders nested and
|
||||
// collapsed by default. Expansion state is remembered per folder.
|
||||
const [expanded, setExpanded] = useState<Record<Id, boolean>>(() => loadRaw("mbx-expanded", {}));
|
||||
const toggle = (id: Id) => {
|
||||
const next = { ...expanded, [id]: !expanded[id] };
|
||||
setExpanded(next);
|
||||
saveJson("mbx-expanded", next);
|
||||
};
|
||||
const rows = useMemo(() => {
|
||||
const all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox");
|
||||
const byParent = new Map<Id | null, Mailbox[]>();
|
||||
for (const m of all) {
|
||||
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
|
||||
byParent.set(p, [...(byParent.get(p) ?? []), m]);
|
||||
}
|
||||
const cmp = (a: Mailbox, b: Mailbox) => {
|
||||
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
|
||||
};
|
||||
const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = [];
|
||||
const subtreeUnread = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + subtreeUnread(c.id), 0);
|
||||
const walk = (parent: Id | null, depth: number) => {
|
||||
for (const m of (byParent.get(parent) ?? []).sort(cmp)) {
|
||||
const kids = byParent.get(m.id) ?? [];
|
||||
const open = Boolean(expanded[m.id]);
|
||||
const childUnread = kids.length ? subtreeUnread(m.id) : 0;
|
||||
out.push({ m, depth, hasChildren: kids.length > 0, open, hiddenUnread: kids.length && !open ? childUnread : 0, childUnread });
|
||||
if (kids.length && open) walk(m.id, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(null, 0);
|
||||
return out;
|
||||
}, [mailboxes, showHidden, expanded]);
|
||||
|
||||
const createFolder = async (parentId: Id | null) => {
|
||||
const name = await promptDialog({ title: parentId ? "New subfolder" : "New folder", placeholder: "Folder name" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
await useMail.getState().createMailbox(name.trim(), parentId);
|
||||
toast.success(`Folder “${name.trim()}” created`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div style={{ padding: "8px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="skeleton" style={{ height: 28, width: `${70 + (i % 3) * 10}%` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav aria-label="Folders" style={{ marginTop: 6 }}>
|
||||
<div className="nav-section">
|
||||
<span>Folders</span>
|
||||
<button className="icon-btn" title="New folder" aria-label="New folder" onClick={() => void createFolder(null)}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{rows.map(({ m, depth, hasChildren, open, hiddenUnread, childUnread }) => (
|
||||
<FolderRow key={m.id} mailbox={m} label={m.name} depth={depth} hasChildren={hasChildren} open={open} hiddenUnread={hiddenUnread} childUnread={childUnread} onToggle={() => toggle(m.id)} currentId={currentId} onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }} />
|
||||
))}
|
||||
{labelsSidebar && labels.length > 0 && (
|
||||
<>
|
||||
<div className="nav-section">
|
||||
<span>Labels</span>
|
||||
<Link href="/settings/labels" className="icon-btn" title="Manage labels" aria-label="Manage labels">
|
||||
<Pencil size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
{labels.map((l) => (
|
||||
<Link key={l.keyword} href={`/search?q=label:${encodeURIComponent(l.keyword)}`} className="nav-item" title={l.name}>
|
||||
<span className="nav-label-color" style={{ background: l.color }} />
|
||||
<span className="nav-label">{l.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={240}>
|
||||
{menuTarget && <MailboxMenu mailbox={menuTarget} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />}
|
||||
</Popover>
|
||||
{shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void }) {
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const own = m.role === "drafts" ? m.totalEmails : m.unreadEmails;
|
||||
const count = own + hiddenUnread;
|
||||
// Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold).
|
||||
const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts";
|
||||
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : <Folder size={20} />;
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (!dropping) setDropping(true);
|
||||
};
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDropping(false);
|
||||
const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
|
||||
if (!raw) return;
|
||||
try {
|
||||
const ids = JSON.parse(raw) as string[];
|
||||
void useMail.getState().move(ids, m.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/mail/${m.id}`}
|
||||
className={`nav-item depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""}`}
|
||||
title={label}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={() => setDropping(false)}
|
||||
onDrop={onDrop}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onMenu(m, { currentTarget: e.currentTarget });
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span
|
||||
className="nav-twisty"
|
||||
role="button"
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
aria-expanded={open}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
) : (
|
||||
depth > 0 && <span style={{ width: 4 }} />
|
||||
)}
|
||||
{icon}
|
||||
<span className="nav-label">{label}</span>
|
||||
{count > 0 && <span className="nav-count" title={hiddenUnread ? `${own} here, ${hiddenUnread} in subfolders` : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
||||
{count > 0 && <span className="nav-dot" />}
|
||||
<button
|
||||
className="icon-btn nav-more"
|
||||
aria-label="Folder options"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMenu(m, e);
|
||||
}}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) {
|
||||
const [, navigate] = useLocation();
|
||||
const rename = async () => {
|
||||
const name = await promptDialog({ title: "Rename folder", defaultValue: m.name });
|
||||
if (!name?.trim() || name.trim() === m.name) return;
|
||||
try {
|
||||
await useMail.getState().updateMailbox(m.id, { name: name.trim() });
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const remove = async () => {
|
||||
const ok = await confirmDialog({ title: `Delete “${m.name}”?`, message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await useMail.getState().destroyMailbox(m.id, true);
|
||||
toast.success("Folder deleted");
|
||||
navigate(`/mail/${useMail.getState().roleId("inbox") ?? ""}`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const empty = async () => {
|
||||
const ok = await confirmDialog({ title: `Empty “${m.name}”?`, message: `All ${m.totalEmails} messages will be permanently deleted.`, confirmLabel: "Empty folder", danger: true });
|
||||
if (ok) await useMail.getState().emptyMailbox(m.id);
|
||||
};
|
||||
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
|
||||
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
|
||||
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} />
|
||||
<MenuSep />
|
||||
{(m.role === "trash" || m.role === "junk") && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />}
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useMail, type ListState } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { formatListDate } from "@/lib/format";
|
||||
import { displayName, shortName } from "@/lib/address";
|
||||
import { Avatar, Empty, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
|
||||
export interface ListActions {
|
||||
archive: (rows?: Id[]) => Promise<void>;
|
||||
trash: (rows?: Id[]) => Promise<void>;
|
||||
spam: (rows?: Id[]) => Promise<void>;
|
||||
read: (read: boolean, rows?: Id[]) => Promise<void>;
|
||||
star: (on: boolean, rows?: Id[]) => Promise<void>;
|
||||
move: (rows?: Id[]) => void;
|
||||
label: (rows: Id[] | undefined, anchor: { x: number; y: number }) => void;
|
||||
moveTo: (ids: Id[], mailboxId: Id) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
list: ListState | null;
|
||||
openThreadId: Id | null;
|
||||
focusId: Id | null;
|
||||
setFocusId: (id: Id | null) => void;
|
||||
onOpen: (rowId: Id) => void;
|
||||
actions: ListActions;
|
||||
mailboxId: Id | null;
|
||||
isSearch: boolean;
|
||||
}
|
||||
|
||||
export function MessageList({ title, list, openThreadId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) {
|
||||
const [, navigate] = useLocation();
|
||||
const emails = useMail((s) => s.emails);
|
||||
const threads = useMail((s) => s.threads);
|
||||
const selected = useMail((s) => s.selected);
|
||||
const select = useMail((s) => s.select);
|
||||
const selectAll = useMail((s) => s.selectAll);
|
||||
const clearSelection = useMail((s) => s.clearSelection);
|
||||
const loadMore = useMail((s) => s.loadMore);
|
||||
const refreshList = useMail((s) => s.refreshList);
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [paneWidth, setPaneWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = parentRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width ?? 0;
|
||||
setPaneWidth(w);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const twoLine = isMobile || (paneWidth > 0 && paneWidth < 640);
|
||||
const ctxMenu = useMenu();
|
||||
const [ctxRow, setCtxRow] = useState<Id | null>(null);
|
||||
const moreMenu = useMenu();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [filterFrom, setFilterFrom] = useState<Email | null>(null);
|
||||
const lastClick = useRef<Id | null>(null);
|
||||
|
||||
const ids = list?.ids ?? [];
|
||||
const selCount = Object.keys(selected).length;
|
||||
const mailbox = mailboxId ? mailboxes[mailboxId] : undefined;
|
||||
const isTrashOrJunk = mailbox?.role === "trash" || mailbox?.role === "junk";
|
||||
const isDrafts = mailbox?.role === "drafts";
|
||||
|
||||
const rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: ids.length + (list && !list.exhausted ? 1 : 0),
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan: 12,
|
||||
});
|
||||
|
||||
// Re-measure when the row height changes (one-line ↔ two-line, density).
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowHeight]);
|
||||
|
||||
// Infinite scroll
|
||||
const items = virtualizer.getVirtualItems();
|
||||
useEffect(() => {
|
||||
const last = items[items.length - 1];
|
||||
if (!last || !list) return;
|
||||
if (last.index >= ids.length - 5 && !list.loadingMore && !list.exhausted && !list.loading) void loadMore();
|
||||
}, [items, ids.length, list, loadMore]);
|
||||
|
||||
// Pull-to-refresh-ish: manual refresh button
|
||||
const doRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refreshList();
|
||||
await useMail.getState().loadMailboxes();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const onRowClick = useCallback(
|
||||
(e: MouseEvent, rowId: Id) => {
|
||||
if (e.shiftKey && lastClick.current) {
|
||||
const a = ids.indexOf(lastClick.current);
|
||||
const b = ids.indexOf(rowId);
|
||||
if (a >= 0 && b >= 0) {
|
||||
const [s, en] = a < b ? [a, b] : [b, a];
|
||||
select(ids.slice(s, en + 1), true);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
select([rowId], !selected[rowId]);
|
||||
lastClick.current = rowId;
|
||||
return;
|
||||
}
|
||||
lastClick.current = rowId;
|
||||
if (selCount > 0 && isMobile) {
|
||||
select([rowId], !selected[rowId]);
|
||||
return;
|
||||
}
|
||||
onOpen(rowId);
|
||||
},
|
||||
[ids, select, selected, selCount, isMobile, onOpen],
|
||||
);
|
||||
|
||||
const onContext = useCallback(
|
||||
(e: MouseEvent, rowId: Id) => {
|
||||
e.preventDefault();
|
||||
setCtxRow(rowId);
|
||||
setFocusId(rowId);
|
||||
ctxMenu.openAt(e.clientX, e.clientY);
|
||||
},
|
||||
[ctxMenu, setFocusId],
|
||||
);
|
||||
|
||||
const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
|
||||
const allSelected = ids.length > 0 && ids.every((id) => selected[id]);
|
||||
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
|
||||
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
|
||||
|
||||
return (
|
||||
<div className="mail-list-pane">
|
||||
<div className="list-toolbar">
|
||||
{isMobile && isSearch && (
|
||||
<button className="icon-btn" onClick={() => navigate("/mail")} aria-label="Back">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="select-all"
|
||||
aria-label="Select all"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selCount > 0 && !allSelected;
|
||||
}}
|
||||
onChange={() => (allSelected || selCount > 0 ? clearSelection() : selectAll())}
|
||||
/>
|
||||
{selCount > 0 ? (
|
||||
<>
|
||||
<span className="tb-count">{selCount} selected</span>
|
||||
<span className="tb-sep" />
|
||||
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive()}><Archive size={19} /></button>
|
||||
<button className="icon-btn" title={isTrashOrJunk ? "Delete forever" : "Delete (#)"} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
||||
<span className="tb-sep" />
|
||||
<button className="icon-btn" title="Mark as read (Shift+I)" onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title="Mark as unread (Shift+U)" onClick={() => void actions.read(false)}><Mail size={19} /></button>
|
||||
<button className="icon-btn" title="Move to (v)" onClick={() => actions.move()}><FolderInput size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="tb-title">{title}</span>
|
||||
{list && !list.loading && <span className="tb-count">{list.total.toLocaleString()}</span>}
|
||||
<span className="spacer" />
|
||||
<button className={`icon-btn ${refreshing ? "active" : ""}`} title="Refresh" onClick={() => void doRefresh()} aria-label="Refresh">
|
||||
<RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? { animation: "spin .8s linear infinite" } : undefined} />
|
||||
</button>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
||||
<MenuTitle>Reading pane</MenuTitle>
|
||||
<MenuItem icon={<PanelRight size={16} />} label="Right of the list" checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} />
|
||||
<MenuItem icon={<PanelBottom size={16} />} label="Below the list" checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} />
|
||||
<MenuItem icon={<PanelTop size={16} />} label="Hidden (open full width)" checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
|
||||
<MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
|
||||
{isTrashOrJunk && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Eraser size={16} />}
|
||||
label={`Empty ${mailbox?.name}`}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog({ title: `Empty ${mailbox?.name}?`, message: "All messages will be permanently deleted.", confirmLabel: "Empty", danger: true })) void useMail.getState().emptyMailbox(mailboxId!);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{list?.error && (
|
||||
<div className="list-hint">
|
||||
<span className="grow" style={{ color: "var(--danger)" }}>{list.error}</span>
|
||||
<button onClick={() => void doRefresh()}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={parentRef} className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} tabIndex={-1}>
|
||||
{list?.loading && ids.length === 0 ? (
|
||||
<div style={{ padding: 8 }}>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="row" style={{ height: rowHeight, padding: "0 8px", gap: 12 }}>
|
||||
<span className="skeleton" style={{ width: 32, height: 32, borderRadius: 16 }} />
|
||||
<span className="skeleton" style={{ width: 140, height: 14 }} />
|
||||
<span className="skeleton grow" style={{ height: 14 }} />
|
||||
<span className="skeleton" style={{ width: 50, height: 12 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : ids.length === 0 && list && !list.loading ? (
|
||||
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}>
|
||||
{isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."}
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="mail-list-inner" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{items.map((vi) => {
|
||||
const id = ids[vi.index];
|
||||
if (!id) {
|
||||
return (
|
||||
<div key="loader" className="list-footer" style={{ position: "absolute", top: vi.start, left: 0, right: 0, height: vi.size }}>
|
||||
{list?.loadingMore ? <span className="spinner" style={{ display: "inline-block" }} /> : ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const e = emails[id];
|
||||
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
|
||||
const thread = list?.collapseThreads ? threads[e.threadId] : undefined;
|
||||
return (
|
||||
<Row
|
||||
key={id}
|
||||
email={e}
|
||||
threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined}
|
||||
top={vi.start}
|
||||
height={vi.size}
|
||||
selected={Boolean(selected[id])}
|
||||
focused={focusId === id}
|
||||
open={openThreadId === e.threadId}
|
||||
twoLine={twoLine}
|
||||
showAvatar={settings.showAvatars}
|
||||
showPreview={settings.showPreview}
|
||||
isDrafts={isDrafts}
|
||||
mailboxId={mailboxId}
|
||||
isSent={mailbox?.role === "sent"}
|
||||
onClick={onRowClick}
|
||||
onContext={onContext}
|
||||
onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }}
|
||||
onStar={(rowId, on) => void actions.star(on, [rowId])}
|
||||
onArchive={(rowId) => void actions.archive([rowId])}
|
||||
onTrash={(rowId) => void actions.trash([rowId])}
|
||||
onRead={(rowId, read) => void actions.read(read, [rowId])}
|
||||
selectedIds={selected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
|
||||
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
|
||||
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Archive size={16} />} label="Archive" kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete" kbd="#" onClick={() => void actions.trash(ctxTargets)} />
|
||||
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(someUnread, ctxTargets)} />
|
||||
<MenuItem icon={<Star size={16} />} label={someUnstarred ? "Add star" : "Remove star"} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} />
|
||||
<MenuItem icon={<FolderInput size={16} />} label="Move to…" kbd="v" onClick={() => actions.move(ctxTargets)} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Label…" kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
|
||||
</Popover>
|
||||
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
email: Email;
|
||||
threadEmails?: Email[];
|
||||
top: number;
|
||||
height: number;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
open: boolean;
|
||||
twoLine: boolean;
|
||||
showAvatar: boolean;
|
||||
showPreview: boolean;
|
||||
isDrafts: boolean;
|
||||
isSent: boolean;
|
||||
mailboxId: Id | null;
|
||||
selectedIds: Record<Id, true>;
|
||||
onClick: (e: MouseEvent, id: Id) => void;
|
||||
onContext: (e: MouseEvent, id: Id) => void;
|
||||
onSelect: (id: Id, on: boolean) => void;
|
||||
onStar: (id: Id, on: boolean) => void;
|
||||
onArchive: (id: Id) => void;
|
||||
onTrash: (id: Id) => void;
|
||||
onRead: (id: Id, read: boolean) => void;
|
||||
}
|
||||
|
||||
const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead }: RowProps) {
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
|
||||
const scope = inScope.length ? inScope : [e];
|
||||
const unread = scope.some((x) => !x.keywords.$seen);
|
||||
const starred = scope.some((x) => x.keywords.$flagged);
|
||||
const hasAtt = scope.some((x) => x.hasAttachment);
|
||||
const answered = e.keywords.$answered;
|
||||
const forwarded = e.keywords.$forwarded;
|
||||
const latest = scope.reduce((a, b) => (a.receivedAt > b.receivedAt ? a : b), scope[0]!);
|
||||
const count = threadEmails ? scope.length : 0;
|
||||
// Participants: Gmail-style "Ann, Bob, Me (3)"
|
||||
const names = useMemo(() => {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const src = isSent || isDrafts ? scope.flatMap((x) => x.to ?? []) : scope.map((x) => x.from?.[0]).filter(Boolean);
|
||||
for (const a of src) {
|
||||
if (!a) continue;
|
||||
const k = a.email.toLowerCase();
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(count > 1 ? shortName(a) : displayName(a));
|
||||
}
|
||||
return out;
|
||||
}, [scope, isSent, isDrafts, count]);
|
||||
const who = (isSent || isDrafts ? (names.length ? `To: ${names.join(", ")}` : "(no recipients)") : names.join(", ")) || "(unknown)";
|
||||
const rowLabels = labels.filter((l) => scope.some((x) => x.keywords[l.keyword]));
|
||||
|
||||
const onDragStart = (ev: DragEvent) => {
|
||||
const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id];
|
||||
// include thread emails in scope
|
||||
const all = new Set<Id>();
|
||||
for (const id of ids) {
|
||||
all.add(id);
|
||||
}
|
||||
for (const x of scope) all.add(x.id);
|
||||
ev.dataTransfer.setData("application/x-ihasmail-emails", JSON.stringify([...all]));
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
const ghost = document.createElement("div");
|
||||
ghost.className = "drag-ghost";
|
||||
ghost.textContent = `${ids.length > 1 ? `${ids.length} conversations` : e.subject || "(no subject)"}`;
|
||||
document.body.appendChild(ghost);
|
||||
ev.dataTransfer.setDragImage(ghost, 10, 10);
|
||||
setTimeout(() => ghost.remove(), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`msg-row ${unread ? "unread" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${open ? "open" : ""}`}
|
||||
style={{ top, height }}
|
||||
data-row-id={e.id}
|
||||
onClick={(ev) => onClick(ev, e.id)}
|
||||
onContextMenu={(ev) => onContext(ev, e.id)}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
role="row"
|
||||
aria-selected={selected}
|
||||
>
|
||||
<input type="checkbox" className="msg-check" checked={selected} onClick={(ev) => ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label="Select" />
|
||||
{!twoLine && (
|
||||
<button className={`msg-star ${starred ? "on" : ""}`} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={starred ? "Unstar" : "Star"}>
|
||||
<Star size={18} fill={starred ? "currentColor" : "none"} />
|
||||
</button>
|
||||
)}
|
||||
{showAvatar && <Avatar who={isSent || isDrafts ? (e.to?.[0] ?? null) : (latest.from?.[0] ?? null)} />}
|
||||
{twoLine ? (
|
||||
<div className="msg-body">
|
||||
<div className="msg-line1">
|
||||
<span className="msg-from truncate">
|
||||
{who}
|
||||
{count > 1 && <span className="thread-count"> {count}</span>}
|
||||
</span>
|
||||
<span className="msg-meta">
|
||||
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msg-main">
|
||||
{isDrafts && <span style={{ color: "var(--danger)" }}>Draft</span>}
|
||||
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||
{showPreview && <span className="msg-preview">{latest.preview}</span>}
|
||||
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label="Star">
|
||||
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
{rowLabels.length > 0 && <div className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="msg-from" title={who}>
|
||||
<span className="truncate">{who}</span>
|
||||
{count > 1 && <span className="thread-count">{count}</span>}
|
||||
</span>
|
||||
<span className="msg-main">
|
||||
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>Draft</span>}
|
||||
{rowLabels.length > 0 && <span className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</span>}
|
||||
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||
{showPreview && <span className="msg-preview">{latest.preview}</span>}
|
||||
</span>
|
||||
<span className="msg-meta">
|
||||
{(answered || forwarded) && <span className="msg-answered" title={answered ? "Replied" : "Forwarded"}>{answered ? <Reply size={14} /> : <Forward size={14} />}</span>}
|
||||
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
||||
<span className="msg-actions">
|
||||
<button className="icon-btn sm" title="Archive" onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
|
||||
<button className="icon-btn sm" title="Delete" onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
|
||||
<button className="icon-btn sm" title={unread ? "Mark as read" : "Mark as unread"} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { client } from "@/jmap/client";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
import { displayName, formatAddress } from "@/lib/address";
|
||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, sanitizeEmailHtml } from "@/lib/html";
|
||||
import { findQuoteStart, textToHtml } from "@/lib/text";
|
||||
import { Avatar } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { ListActions } from "./MessageList";
|
||||
import { InviteCard } from "./InviteCard";
|
||||
import { VCardCard } from "./VCardCard";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
isLast: boolean;
|
||||
actions: ListActions;
|
||||
}
|
||||
|
||||
export const MessageView = memo(function MessageView({ email: e, expanded, onToggle, actions }: Props) {
|
||||
const accountId = useMail((s) => s.accountId)!;
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const reply = useCompose((s) => s.reply);
|
||||
const [details, setDetails] = useState(false);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [showHeaders, setShowHeaders] = useState(false);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [allowRemote, setAllowRemote] = useState(false);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const moreMenu = useMenu();
|
||||
const from = e.from?.[0];
|
||||
const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase());
|
||||
const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email)));
|
||||
const remoteAllowed = allowRemote || settings.imagePolicy === "always" || senderTrusted || (settings.imagePolicy === "contacts" && inContacts);
|
||||
const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true);
|
||||
|
||||
const htmlPart = e.htmlBody?.[0];
|
||||
const textPart = e.textBody?.[0];
|
||||
const htmlRaw = htmlPart?.partId ? e.bodyValues?.[htmlPart.partId]?.value : undefined;
|
||||
const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined;
|
||||
const showHtml = Boolean(htmlRaw);
|
||||
|
||||
// Inline images map
|
||||
const cidMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const a of e.attachments ?? []) if (a.cid && a.blobId) map[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
|
||||
const walk = (p?: EmailBodyPart) => {
|
||||
if (!p) return;
|
||||
if (p.cid && p.blobId && !map[p.cid]) map[p.cid] = client.downloadUrl(accountId, p.blobId, p.name ?? "image", p.type, true);
|
||||
p.subParts?.forEach(walk);
|
||||
};
|
||||
walk(e.bodyStructure);
|
||||
return map;
|
||||
}, [e.attachments, e.bodyStructure, accountId]);
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
if (!expanded) return null;
|
||||
if (showHtml) return sanitizeEmailHtml(htmlRaw!, { cidMap, allowRemote: remoteAllowed, proxyRemote: imageProxy });
|
||||
return null;
|
||||
}, [expanded, showHtml, htmlRaw, cidMap, remoteAllowed, imageProxy]);
|
||||
|
||||
const attachments = useMemo(() => (e.attachments ?? []).filter((a) => !(a.cid && a.disposition === "inline" && a.type.startsWith("image/") && htmlRaw?.includes(`cid:${a.cid}`))), [e.attachments, htmlRaw]);
|
||||
const icsPart = useMemo(() => findPart(e.bodyStructure, (p) => p.type === "text/calendar" || (p.name ?? "").toLowerCase().endsWith(".ics")), [e.bodyStructure]);
|
||||
const vcfParts = useMemo(() => (e.attachments ?? []).filter((p) => p.type === "text/vcard" || p.type === "text/x-vcard" || (p.name ?? "").toLowerCase().endsWith(".vcf")), [e.attachments]);
|
||||
const unsubscribe = e["header:List-Unsubscribe:asText"];
|
||||
const isHighPriority = /^[12]/.test(e["header:X-Priority:asText"] ?? "") || /high/i.test(e["header:Importance:asText"] ?? "");
|
||||
const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length);
|
||||
const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
|
||||
|
||||
const openSource = async () => {
|
||||
setShowSource(true);
|
||||
if (source === null) {
|
||||
try {
|
||||
setSource(await client.fetchBlobText(accountId, e.blobId, "message/rfc822"));
|
||||
} catch (err) {
|
||||
setSource(`Could not load source: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const downloadEml = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = client.downloadUrl(accountId, e.blobId, `${(e.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822");
|
||||
a.download = "";
|
||||
a.click();
|
||||
};
|
||||
|
||||
const onUnsubscribe = async () => {
|
||||
if (!unsubscribe) return;
|
||||
const urls = [...unsubscribe.matchAll(/<([^>]+)>/g)].map((m) => m[1]!);
|
||||
const mailto = urls.find((u) => u.startsWith("mailto:"));
|
||||
const http = urls.find((u) => /^https?:/i.test(u));
|
||||
if (mailto) {
|
||||
const [addr, qs] = mailto.slice(7).split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
useCompose.getState().open({ to: [{ name: null, email: addr ?? "" }], subject: q.get("subject") ?? "unsubscribe", html: `<div>${q.get("body") ?? "unsubscribe"}</div>`, text: q.get("body") ?? "unsubscribe" });
|
||||
toast.show("Unsubscribe message prepared — just hit Send");
|
||||
} else if (http) {
|
||||
window.open(http, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const collapsedClick = () => {
|
||||
if (!expanded) onToggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={`message ${expanded ? "" : "collapsed"} ${!e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
|
||||
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
|
||||
<Avatar who={from ?? null} />
|
||||
<div className="who">
|
||||
<div className="from">
|
||||
<span>{displayName(from)}</span>
|
||||
{expanded && from && <span className="email"><{from.email}></span>}
|
||||
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>Important</span>}
|
||||
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="to">
|
||||
<span className="truncate">to {summarizeRecipients(e)}</span>
|
||||
<button onClick={(ev) => { ev.stopPropagation(); setDetails((v) => !v); }} aria-label="Show details" title="Show details">
|
||||
{details ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="snippet">{e.preview}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="meta">
|
||||
{e.hasAttachment && !expanded && <Paperclip size={14} />}
|
||||
<span className="date" title={formatFullDate(e.receivedAt)}>{expanded ? formatFullDate(e.receivedAt) : formatListDate(e.receivedAt)}</span>
|
||||
<button className={`icon-btn sm ${e.keywords.$flagged ? "active" : ""}`} style={e.keywords.$flagged ? { color: "var(--star)", background: "transparent" } : undefined} title="Star" onClick={(ev) => { ev.stopPropagation(); void actions.star(!e.keywords.$flagged, [e.id]); }}>
|
||||
<Star size={17} fill={e.keywords.$flagged ? "currentColor" : "none"} />
|
||||
</button>
|
||||
{expanded && (
|
||||
<>
|
||||
<button className="icon-btn sm hide-mobile" title="Reply (r)" onClick={(ev) => { ev.stopPropagation(); void reply(e, "reply"); }}><Reply size={17} /></button>
|
||||
<button className="icon-btn sm" onClick={(ev) => { ev.stopPropagation(); moreMenu.open(ev); }} aria-label="More"><MoreVertical size={17} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
||||
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => void reply(e, "reply")} />
|
||||
<MenuItem icon={<ReplyAll size={16} />} label="Reply all" onClick={() => void reply(e, "replyAll")} />
|
||||
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => void reply(e, "forward")} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete this message" onClick={() => void useMail.getState().trash([e.id])} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Eye size={16} />} label="Show original" onClick={() => void openSource()} />
|
||||
<MenuItem icon={<Code size={16} />} label="Show headers" onClick={() => setShowHeaders(true)} />
|
||||
<MenuItem icon={<Download size={16} />} label="Download (.eml)" onClick={downloadEml} />
|
||||
<MenuItem icon={<Printer size={16} />} label="Print" onClick={() => window.print()} />
|
||||
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => setFilterOpen(true)} />
|
||||
{from && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Ban size={16} />} label={senderTrusted ? "Stop trusting sender images" : "Always show images from sender"} onClick={() => updateSettings({ trustedImageSenders: senderTrusted ? settings.trustedImageSenders.filter((x) => x !== from.email.toLowerCase()) : [...settings.trustedImageSenders, from.email.toLowerCase()] })} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
{details && (
|
||||
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
|
||||
<dt>From</dt><dd>{(e.from ?? []).map(formatAddress).join(", ")}</dd>
|
||||
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd>{e.sender.map(formatAddress).join(", ")}</dd></> : null}
|
||||
{e.replyTo?.length ? <><dt>Reply-To</dt><dd>{e.replyTo.map(formatAddress).join(", ")}</dd></> : null}
|
||||
<dt>To</dt><dd>{(e.to ?? []).map(formatAddress).join(", ") || "—"}</dd>
|
||||
{e.cc?.length ? <><dt>Cc</dt><dd>{e.cc.map(formatAddress).join(", ")}</dd></> : null}
|
||||
{e.bcc?.length ? <><dt>Bcc</dt><dd>{e.bcc.map(formatAddress).join(", ")}</dd></> : null}
|
||||
<dt>Date</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
|
||||
<dt>Subject</dt><dd>{e.subject || "(no subject)"}</dd>
|
||||
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
|
||||
{e["header:List-Id:asText"] && <><dt>List</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>Size</dt><dd>{formatSize(e.size)}</dd>
|
||||
{receiptRequested && <><dt>Receipt</dt><dd>The sender requested a read receipt (not sent automatically).</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
|
||||
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<ImageIcon size={16} />
|
||||
<span className="grow">Remote images are blocked to protect your privacy.</span>
|
||||
<button onClick={() => setAllowRemote(true)}>Show images</button>
|
||||
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>Always from {from.email}</button>}
|
||||
</div>
|
||||
)}
|
||||
{icsPart && <InviteCard email={e} part={icsPart} />}
|
||||
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
|
||||
<div className="message-body">
|
||||
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} onShowImages={() => setAllowRemote(true)} /> : <TextBody text={textRaw ?? ""} />}
|
||||
</div>
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
|
||||
{unsubscribe && (
|
||||
<div className="unsubscribe-row">
|
||||
<span>This looks like a mailing list.</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>Unsubscribe</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
|
||||
<Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl">
|
||||
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
|
||||
</Dialog>
|
||||
<Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title="Message headers" size="lg">
|
||||
<dl className="message-details" style={{ margin: 0 }}>
|
||||
{Object.entries(e).filter(([k]) => k.startsWith("header:")).map(([k, v]) => (
|
||||
<>
|
||||
<dt key={`${k}-t`}>{k.split(":")[1]}</dt>
|
||||
<dd key={`${k}-d`} className="mono small">{Array.isArray(v) ? v.map((x: unknown) => (typeof x === "object" && x ? formatAddress(x as EmailAddress) : String(x))).join(", ") : String(v ?? "—")}</dd>
|
||||
</>
|
||||
))}
|
||||
<dt>Received</dt><dd>{formatFullDate(e.receivedAt)}</dd>
|
||||
{e.inReplyTo?.length ? <><dt>In-Reply-To</dt><dd className="mono small">{e.inReplyTo.join(" ")}</dd></> : null}
|
||||
{e.references?.length ? <><dt>References</dt><dd className="mono small">{e.references.join(" ")}</dd></> : null}
|
||||
</dl>
|
||||
<p className="hint">Use “Show original” for the complete raw message.</p>
|
||||
</Dialog>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
function summarizeRecipients(e: Email): string {
|
||||
const all = [...(e.to ?? []), ...(e.cc ?? [])];
|
||||
if (!all.length) return "(undisclosed recipients)";
|
||||
const me = useMail.getState().identities.map((i) => i.email.toLowerCase());
|
||||
const names = all.map((a) => (me.includes(a.email.toLowerCase()) ? "me" : displayName(a).split(" ")[0] || a.email));
|
||||
if (names.length <= 3) return names.join(", ");
|
||||
return `${names.slice(0, 3).join(", ")} +${names.length - 3}`;
|
||||
}
|
||||
|
||||
function findPart(p: EmailBodyPart | undefined, pred: (p: EmailBodyPart) => boolean): EmailBodyPart | null {
|
||||
if (!p) return null;
|
||||
if (pred(p)) return p;
|
||||
for (const s of p.subParts ?? []) {
|
||||
const r = findPart(s, pred);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Body renderers ---------- */
|
||||
|
||||
const QUOTE_SELECTORS = [".gmail_quote", "blockquote[type=cite]", ".moz-cite-prefix", "#divRplyFwdMsg", ".yahoo_quoted", "div[id^=appendonsend]", ".ms-outlook-mobile-reference-message", "#OLK_SRC_BODY_SECTION", ".protonmail_quote", ".ihm-quote"];
|
||||
|
||||
function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle: string; onShowImages: () => void }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [hasQuote, setHasQuote] = useState(false);
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
const onClick = useCallback(
|
||||
(ev: Event) => {
|
||||
const t = ev.target as HTMLElement;
|
||||
const a = t.closest("a");
|
||||
if (a) {
|
||||
const href = a.getAttribute("href") ?? "";
|
||||
if (href.startsWith("mailto:")) {
|
||||
ev.preventDefault();
|
||||
const [addr, qs] = href.slice(7).split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
openCompose({ to: addr ? addr.split(",").map((x) => ({ name: null, email: decodeURIComponent(x.trim()) })) : [], subject: q.get("subject") ?? "", html: q.get("body") ? `<div>${q.get("body")}</div>` : "" });
|
||||
return;
|
||||
}
|
||||
if (/^(javascript|data|vbscript):/i.test(href)) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
}
|
||||
const img = t.closest("img[data-ihm-blocked]");
|
||||
if (img) onShowImages();
|
||||
},
|
||||
[openCompose, onShowImages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `<style>${EMAIL_BASE_CSS}</style><div class="ihm-email-root" style="${bodyStyle.replace(/"/g, "'")}">${html}</div>`;
|
||||
// Collapse quoted content
|
||||
const container = root.querySelector(".ihm-email-root") as HTMLElement | null;
|
||||
let found = false;
|
||||
if (container) {
|
||||
let q: Element | null = null;
|
||||
for (const sel of QUOTE_SELECTORS) {
|
||||
q = container.querySelector(sel);
|
||||
if (q) break;
|
||||
}
|
||||
if (!q) {
|
||||
// Heuristic: a blockquote preceded by text ending in "wrote:"
|
||||
const bqs = Array.from(container.querySelectorAll("blockquote"));
|
||||
for (const bq of bqs) {
|
||||
const prev = bq.previousElementSibling;
|
||||
if (prev && /wrote:\s*$|Original Message|Von:|De :|From:/i.test(prev.textContent ?? "")) {
|
||||
q = prev;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!q && bqs.length === 1 && (bqs[0]!.textContent?.length ?? 0) > 200) q = bqs[0]!;
|
||||
}
|
||||
if (q && q.parentElement) {
|
||||
// Move q and subsequent siblings into a hidden wrapper (only if q isn't the whole body)
|
||||
const parent = q.parentElement;
|
||||
const textBefore = (container.textContent ?? "").indexOf((q.textContent ?? "").slice(0, 40));
|
||||
if (textBefore > 0 || q.previousElementSibling) {
|
||||
const wrap = root.ownerDocument.createElement("div");
|
||||
wrap.className = "ihm-quoted";
|
||||
wrap.hidden = true;
|
||||
const nodes: ChildNode[] = [];
|
||||
let n: ChildNode | null = q.classList.contains("moz-cite-prefix") ? q : q;
|
||||
while (n) {
|
||||
nodes.push(n);
|
||||
n = n.nextSibling;
|
||||
}
|
||||
parent.insertBefore(wrap, q);
|
||||
for (const node of nodes) wrap.appendChild(node);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
setHasQuote(found);
|
||||
setQuoteOpen(false);
|
||||
root.addEventListener("click", onClick);
|
||||
return () => root.removeEventListener("click", onClick);
|
||||
}, [html, bodyStyle, onClick]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = hostRef.current?.shadowRoot;
|
||||
const q = root?.querySelector<HTMLElement>(".ihm-quoted");
|
||||
if (q) q.hidden = !quoteOpen;
|
||||
}, [quoteOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={hostRef} className="body-host" />
|
||||
{hasQuote && (
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>•••</span>}
|
||||
{quoteOpen ? "Hide quoted text" : ""}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextBody({ text }: { text: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
const { main, quoted } = useMemo(() => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const idx = findQuoteStart(lines);
|
||||
if (idx > 2) return { main: lines.slice(0, idx).join("\n"), quoted: lines.slice(idx).join("\n") };
|
||||
return { main: text, quoted: "" };
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `<style>${TEXT_EMAIL_CSS}</style><div class="ihm-text-root">${textToHtml(main)}${quoted ? `<div class="ihm-quoted" ${quoteOpen ? "" : "hidden"}>\n${textToHtml(quoted)}</div>` : ""}</div>`;
|
||||
const onClick = (ev: Event) => {
|
||||
const a = (ev.target as HTMLElement).closest("a");
|
||||
if (a && a.getAttribute("href")?.startsWith("mailto:")) {
|
||||
ev.preventDefault();
|
||||
openCompose({ to: [{ name: null, email: a.getAttribute("href")!.slice(7) }] });
|
||||
}
|
||||
};
|
||||
root.addEventListener("click", onClick);
|
||||
return () => root.removeEventListener("click", onClick);
|
||||
}, [main, quoted, quoteOpen, openCompose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={hostRef} className="body-host" />
|
||||
{quoted && (
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>•••</span>}
|
||||
{quoteOpen ? "Hide quoted text" : ""}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Attachments ---------- */
|
||||
|
||||
export function attachmentIcon(type: string, name?: string | null) {
|
||||
const t = type.toLowerCase();
|
||||
const n = (name ?? "").toLowerCase();
|
||||
if (t.startsWith("image/")) return <ImageIcon size={18} />;
|
||||
if (t.startsWith("video/")) return <Film size={18} />;
|
||||
if (t.startsWith("audio/")) return <Music size={18} />;
|
||||
if (t === "application/pdf") return <FileText size={18} />;
|
||||
if (/zip|tar|gzip|7z|rar|compressed/.test(t) || /\.(zip|tgz|gz|7z|rar)$/.test(n)) return <FileArchive size={18} />;
|
||||
if (/spreadsheet|excel|csv/.test(t) || /\.(xlsx?|csv)$/.test(n)) return <FileSpreadsheet size={18} />;
|
||||
if (t === "text/calendar") return <Calendar size={18} />;
|
||||
if (t.includes("vcard")) return <UserPlus size={18} />;
|
||||
if (t.startsWith("text/") || /word|document/.test(t)) return <FileText size={18} />;
|
||||
return <File size={18} />;
|
||||
}
|
||||
|
||||
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
|
||||
const [preview, setPreview] = useState<EmailBodyPart | null>(null);
|
||||
const viewable = (a: EmailBodyPart) => (a.type.startsWith("image/") && a.type !== "image/svg+xml") || a.type === "application/pdf" || a.type === "text/plain";
|
||||
return (
|
||||
<>
|
||||
<div className="attachments">
|
||||
{attachments.map((a, i) => {
|
||||
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";
|
||||
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#";
|
||||
return (
|
||||
<a key={a.blobId ?? i} className="attachment" href={url} download={a.name ?? undefined} title={`${a.name ?? "attachment"} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
|
||||
<span className="att-icon">{a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? <img src={inlineUrl} alt="" loading="lazy" /> : attachmentIcon(a.type, a.name)}</span>
|
||||
<span className="att-text">
|
||||
<span className="att-name">{a.name ?? "(unnamed)"}</span>
|
||||
<span className="att-size">{formatSize(a.size)}</span>
|
||||
<span className="att-actions">
|
||||
<button className="icon-btn xs" title="Download" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
|
||||
{viewable(a) && <button className="icon-btn xs" title="Open in new tab" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
{attachments.length > 1 && (
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "center" }} onClick={() => { for (const a of attachments) { if (!a.blobId) continue; const l = document.createElement("a"); l.href = client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type); l.download = a.name ?? ""; l.click(); } }}>
|
||||
<Download size={14} /> Download all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> Download</a>}>
|
||||
{preview?.type.startsWith("image/") && <img src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "image", preview.type, true)} alt={preview.name ?? ""} style={{ maxHeight: "70vh", display: "block", margin: "0 auto" }} />}
|
||||
{preview?.type === "application/pdf" && <iframe title="PDF" src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
|
||||
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
|
||||
<p className="hint" style={{ marginTop: 8 }}>From: {displayName(email.from?.[0])}</p>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextAttachment({ url }: { url: string }) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
fetch(url, { credentials: "same-origin" }).then((r) => r.text()).then(setText).catch(() => setText("Could not load."));
|
||||
}, [url]);
|
||||
return <pre className="code" style={{ maxHeight: "65vh" }}>{text ?? "Loading…"}</pre>;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { MessageView } from "./MessageView";
|
||||
import type { ListActions } from "./MessageList";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { client } from "@/jmap/client";
|
||||
import { LabelPicker } from "./LabelPicker";
|
||||
|
||||
interface Props {
|
||||
threadId: Id;
|
||||
mailboxId: Id | null;
|
||||
onBack: () => void;
|
||||
actions: ListActions;
|
||||
onNavigate: (delta: number) => void;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, hasPrev, hasNext }: Props) {
|
||||
const loadThread = useMail((s) => s.loadThread);
|
||||
const thread = useMail((s) => s.threads[threadId]);
|
||||
const emails = useMail((s) => s.emails);
|
||||
const fullIds = useMail((s) => s.fullIds);
|
||||
const loading = useMail((s) => Boolean(s.loadingThreads[threadId]));
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const labels = settings.labels;
|
||||
const reply = useCompose((s) => s.reply);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<Id, boolean>>({});
|
||||
const [allExpanded, setAllExpanded] = useState(false);
|
||||
const [labelAnchor, setLabelAnchor] = useState<{ x: number; y: number } | null>(null);
|
||||
const moreMenu = useMenu();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const markTimer = useRef<number | null>(null);
|
||||
|
||||
// Load
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
useMail.getState().setOpenThread(threadId);
|
||||
loadThread(threadId).catch((err) => setError((err as Error).message));
|
||||
return () => {
|
||||
if (useMail.getState().openThreadId === threadId) useMail.getState().setOpenThread(null);
|
||||
};
|
||||
}, [threadId, loadThread]);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
if (!thread) return [] as Email[];
|
||||
const all = thread.emailIds.map((id) => emails[id]).filter((e): e is Email => Boolean(e && fullIds[e.id]));
|
||||
// Conversation view: hide trash/junk messages unless we're in that folder.
|
||||
const mail = useMail.getState();
|
||||
const trash = mail.roleId("trash");
|
||||
const junk = mail.roleId("junk");
|
||||
const filtered = all.filter((e) => {
|
||||
if (mailboxId && (mailboxId === trash || mailboxId === junk)) return true;
|
||||
if (trash && e.mailboxIds[trash]) return false;
|
||||
if (junk && e.mailboxIds[junk]) return false;
|
||||
return true;
|
||||
});
|
||||
return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
|
||||
}, [thread, emails, fullIds, mailboxId]);
|
||||
|
||||
// Default expansion: unread + last message expanded, others collapsed
|
||||
const lastId = messages[messages.length - 1]?.id;
|
||||
const isExpanded = useCallback(
|
||||
(e: Email) => {
|
||||
if (e.id in expanded) return expanded[e.id]!;
|
||||
if (allExpanded) return true;
|
||||
return !e.keywords.$seen || e.id === lastId || messages.length === 1;
|
||||
},
|
||||
[expanded, allExpanded, lastId, messages.length],
|
||||
);
|
||||
|
||||
// Mark as read after delay
|
||||
useEffect(() => {
|
||||
if (!messages.length) return;
|
||||
const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e)).map((e) => e.id);
|
||||
if (!unread.length || settings.markReadDelay < 0) return;
|
||||
if (markTimer.current) window.clearTimeout(markTimer.current);
|
||||
markTimer.current = window.setTimeout(() => void useMail.getState().markRead(unread, true), settings.markReadDelay * 1000);
|
||||
return () => {
|
||||
if (markTimer.current) window.clearTimeout(markTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay]);
|
||||
|
||||
// Scroll last expanded into view on load
|
||||
useEffect(() => {
|
||||
if (!messages.length || !scrollRef.current) return;
|
||||
const el = scrollRef.current.querySelector<HTMLElement>(`[data-msg-id="${CSS.escape(lastId ?? "")}"]`);
|
||||
if (el && messages.length > 1) el.scrollIntoView({ block: "start" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [threadId, messages.length > 0]);
|
||||
|
||||
// Keyboard: reply/forward events from MailView
|
||||
useEffect(() => {
|
||||
const onReply = (ev: Event) => {
|
||||
const mode = (ev as CustomEvent<"reply" | "replyAll" | "forward">).detail;
|
||||
const last = messages[messages.length - 1];
|
||||
if (last) void reply(last, mode);
|
||||
};
|
||||
const onNav = (ev: Event) => {
|
||||
const delta = (ev as CustomEvent<number>).detail;
|
||||
const els = Array.from(scrollRef.current?.querySelectorAll<HTMLElement>("[data-msg-id]") ?? []);
|
||||
if (!els.length) return;
|
||||
const top = scrollRef.current!.getBoundingClientRect().top;
|
||||
let idx = els.findIndex((el) => el.getBoundingClientRect().top - top > 8);
|
||||
if (idx < 0) idx = els.length;
|
||||
const target = els[Math.max(0, Math.min(els.length - 1, (delta > 0 ? idx : idx - 2)))];
|
||||
if (target) {
|
||||
const id = target.dataset.msgId!;
|
||||
setExpanded((x) => ({ ...x, [id]: true }));
|
||||
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
window.addEventListener("ihm:reply", onReply);
|
||||
window.addEventListener("ihm:msg-nav", onNav);
|
||||
return () => {
|
||||
window.removeEventListener("ihm:reply", onReply);
|
||||
window.removeEventListener("ihm:msg-nav", onNav);
|
||||
};
|
||||
}, [messages, reply]);
|
||||
|
||||
const subject = messages[0]?.subject || emails[thread?.emailIds[0] ?? ""]?.subject || "(no subject)";
|
||||
const rowIds = thread ? thread.emailIds.filter((id) => emails[id]) : [];
|
||||
const anyUnread = messages.some((e) => !e.keywords.$seen);
|
||||
const anyStarred = messages.some((e) => e.keywords.$flagged);
|
||||
const inJunk = Boolean(mailboxId && mailboxes[mailboxId]?.role === "junk");
|
||||
const threadLabels = labels.filter((l) => messages.some((m) => m.keywords[l.keyword]));
|
||||
const threadMailboxes = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const m of messages) for (const id of Object.keys(m.mailboxIds)) if (mailboxes[id] && id !== mailboxId) set.add(mailboxes[id]!.name);
|
||||
return [...set];
|
||||
}, [messages, mailboxes, mailboxId]);
|
||||
|
||||
const last = messages[messages.length - 1];
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
|
||||
return (
|
||||
<div className="thread-view">
|
||||
<div className="thread-toolbar">
|
||||
<button className="icon-btn" onClick={onBack} aria-label="Back to list" title="Back (u)">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
|
||||
<button className="icon-btn" title={inJunk ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
||||
<button className="icon-btn" title="Delete (#)" onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
|
||||
<span className="tb-sep hide-mobile" />
|
||||
<button className="icon-btn hide-mobile" title={anyUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
|
||||
<button className="icon-btn hide-mobile" title="Move to (v)" onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More"><MoreVertical size={19} /></button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}>
|
||||
<MenuItem icon={<Star size={16} />} label={anyStarred ? "Remove star" : "Add star"} onClick={() => void actions.star(!anyStarred, rowIds)} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Label…" onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
|
||||
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Printer size={16} />} label="Print conversation" onClick={() => window.print()} />
|
||||
{last && accountId && (
|
||||
<MenuItem icon={<Download size={16} />} label="Download latest as .eml" onClick={() => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, last.blobId, `${(last.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822"); a.download = ""; a.click(); }} />
|
||||
)}
|
||||
</Popover>
|
||||
<div className="thread-nav hide-mobile">
|
||||
<button className="icon-btn sm" disabled={!hasPrev} onClick={() => onNavigate(-1)} title="Newer (k)"><ChevronUp size={18} /></button>
|
||||
<button className="icon-btn sm" disabled={!hasNext} onClick={() => onNavigate(1)} title="Older (j)"><ChevronDown size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="thread-scroll" ref={scrollRef}>
|
||||
<div className="thread-subject">
|
||||
<div className="grow">
|
||||
<h1>{subject}</h1>
|
||||
{(threadLabels.length > 0 || threadMailboxes.length > 0) && (
|
||||
<div className="labels">
|
||||
{threadMailboxes.map((n) => <span key={n} className="chip">{n}</span>)}
|
||||
{threadLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{messages.length > 1 && <span className="muted small nowrap" style={{ marginTop: 6 }}>{messages.length} messages</span>}
|
||||
</div>
|
||||
{error && <div className="error-box" style={{ margin: 16 }}>{error}</div>}
|
||||
{loading && !messages.length && <Spinner label="Loading conversation…" />}
|
||||
{messages.map((e, i) => (
|
||||
<MessageView
|
||||
key={e.id}
|
||||
email={e}
|
||||
expanded={isExpanded(e)}
|
||||
onToggle={() => setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))}
|
||||
isLast={i === messages.length - 1}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
{last && (
|
||||
<div className="reply-box">
|
||||
<div className="reply-prompt">
|
||||
<button onClick={() => void reply(last, "reply")}><Reply size={16} /> Reply</button>
|
||||
<button onClick={() => void reply(last, "replyAll")}><ReplyAll size={16} /> Reply all</button>
|
||||
<button onClick={() => void reply(last, "forward")}><Forward size={16} /> Forward</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{labelAnchor && <LabelPicker ids={rowIds} anchor={labelAnchor} onClose={() => setLabelAnchor(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import type { EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { client } from "@/jmap/client";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
|
||||
const contacts = useContacts();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
if (!contacts.available || !part.blobId) return null;
|
||||
const add = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const text = await client.fetchBlobText(accountId, part.blobId!, "text/vcard");
|
||||
const book = Object.values(contacts.books).find((b) => b.isDefault) ?? Object.values(contacts.books)[0];
|
||||
if (!book) throw new Error("No address book available");
|
||||
const n = await contacts.importVCard(text, book.id);
|
||||
setDone(true);
|
||||
toast.success(`Added ${n} contact${n === 1 ? "" : "s"}`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="vcard-card">
|
||||
<UserPlus size={20} style={{ color: "var(--accent)" }} />
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 600 }}>{part.name ?? "Contact card"}</div>
|
||||
<div className="hint">vCard attachment</div>
|
||||
</div>
|
||||
<button className="btn btn-sm" disabled={busy || done} onClick={() => void add()}>{done ? "Added" : "Add to contacts"}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useSession } from "@/store/session";
|
||||
import { client } from "@/jmap/client";
|
||||
|
||||
export function AboutSettings() {
|
||||
const session = useSession((s) => s.session);
|
||||
const caps = Object.keys(session?.capabilities ?? {});
|
||||
return (
|
||||
<div>
|
||||
<h1>About ihasmail</h1>
|
||||
<p className="lead">A fast, friendly, open-source webmail for <a href="https://stalw.art" target="_blank" rel="noreferrer">Stalwart Mail Server</a>, built on JMAP.</p>
|
||||
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
||||
<img src="/img/logo.png" alt="ihasmail" width={96} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div>
|
||||
<div className="hint">GPL-3.0-or-later · <a href="https://github.com/LINUXexpert-org/ihasmail" target="_blank" rel="noreferrer">github.com/LINUXexpert-org/ihasmail</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Server</h2>
|
||||
<table className="sessions-table">
|
||||
<tbody>
|
||||
<tr><td>Signed in as</td><td>{session?.username}</td></tr>
|
||||
<tr><td>Accounts</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
|
||||
<tr><td>Max upload</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
|
||||
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Server capabilities</h2>
|
||||
<div className="row wrap gap-4">
|
||||
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
|
||||
const ACCENTS = [
|
||||
{ id: "teal", color: "#0f766e" },
|
||||
{ id: "blue", color: "#2563eb" },
|
||||
{ id: "purple", color: "#7c3aed" },
|
||||
{ id: "rose", color: "#e11d48" },
|
||||
{ id: "orange", color: "#ea580c" },
|
||||
{ id: "green", color: "#16a34a" },
|
||||
];
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
return (
|
||||
<div>
|
||||
<h1>Appearance</h1>
|
||||
<p className="lead">Make ihasmail yours.</p>
|
||||
<h2>Theme</h2>
|
||||
<div className="theme-grid">
|
||||
{(["system", "light", "dark"] as const).map((t) => (
|
||||
<button key={t} className={`theme-card ${s.theme === t ? "active" : ""}`} onClick={() => update({ theme: t })}>
|
||||
<div className="preview" style={{ background: t === "dark" ? "#0b1220" : t === "light" ? "#f6f8fa" : "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" }} />
|
||||
{t === "system" ? "Match system" : t === "light" ? "Light" : "Dark"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<h2>Accent color</h2>
|
||||
<div className="swatches">
|
||||
{ACCENTS.map((a) => (
|
||||
<button key={a.id} className={`swatch ${s.accent === a.id ? "active" : ""}`} style={{ background: a.color }} onClick={() => update({ accent: a.id })} aria-label={a.id} title={a.id} />
|
||||
))}
|
||||
</div>
|
||||
<h2>Density & text</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Display density</label>
|
||||
<select className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
|
||||
<option value="comfortable">Comfortable</option>
|
||||
<option value="cozy">Cozy (default)</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Text size</label>
|
||||
<select className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Sidebar</h2>
|
||||
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label="Show labels in the sidebar" />
|
||||
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label="Show unsubscribed (hidden) folders" />
|
||||
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label="Collapse sidebar to icons" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { ColorSwatches, CALENDAR_COLORS } from "@/ui/misc";
|
||||
import { promptDialog } from "@/ui/dialog";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
export function CalendarSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
return (
|
||||
<div>
|
||||
<h1>Calendar & contacts</h1>
|
||||
<p className="lead">Defaults for the calendar views and new events.</p>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Default view</label>
|
||||
<select className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="agenda">Agenda</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Default event length</label>
|
||||
<select className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}>
|
||||
<option value="15">15 minutes</option>
|
||||
<option value="30">30 minutes</option>
|
||||
<option value="45">45 minutes</option>
|
||||
<option value="60">1 hour</option>
|
||||
<option value="90">1.5 hours</option>
|
||||
<option value="120">2 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Default reminder</label>
|
||||
<select className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}>
|
||||
<option value="-1">None</option>
|
||||
<option value="0">At time of event</option>
|
||||
<option value="5">5 minutes before</option>
|
||||
<option value="10">10 minutes before</option>
|
||||
<option value="15">15 minutes before</option>
|
||||
<option value="30">30 minutes before</option>
|
||||
<option value="60">1 hour before</option>
|
||||
<option value="1440">1 day before</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Colour categories</h2>
|
||||
<p className="hint">Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.</p>
|
||||
{s.eventCategories.map((c, i) => (
|
||||
<div key={c.name} className="card">
|
||||
<div className="card-head">
|
||||
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
|
||||
<h3>{c.name}</h3>
|
||||
<button className="icon-btn sm" title="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}>✎</button>
|
||||
<button className="icon-btn sm danger" aria-label="Delete category" onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> New category</button>
|
||||
|
||||
<h2>Working hours</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Working hours start</label>
|
||||
<select className="select" value={String(s.workDayStart)} onChange={(e) => update({ workDayStart: Number(e.target.value) })}>
|
||||
{[...Array(24)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Working hours end</label>
|
||||
<select className="select" value={String(s.workDayEnd)} onChange={(e) => update({ workDayEnd: Number(e.target.value) })}>
|
||||
{[...Array(25)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Week starts on</label>
|
||||
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
|
||||
<option value="1">Monday</option>
|
||||
<option value="0">Sunday</option>
|
||||
<option value="6">Saturday</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Code, Plus, Trash2, Wand2, Play, AlertTriangle, Power } from "lucide-react";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { describeRule, newRule, rulesToSieve, type SieveRule } from "@/lib/sieve";
|
||||
import { RuleDialog } from "./RuleDialog";
|
||||
import { saveAndApply } from "../mail/FilterFromMessage";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { Switch, Spinner } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { SieveScript } from "@/jmap/types";
|
||||
|
||||
export function FiltersSettings() {
|
||||
const sieve = useSieve();
|
||||
const [tab, setTab] = useState<"rules" | "scripts">("rules");
|
||||
useEffect(() => {
|
||||
if (sieve.available && !sieve.scripts.length && !sieve.loading) void sieve.load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sieve.available]);
|
||||
|
||||
if (!sieve.available) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Filters & rules</h1>
|
||||
<p className="lead">Sieve filtering is not available for this account.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Filters & rules</h1>
|
||||
<p className="lead">Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.</p>
|
||||
<div className="view-switch" style={{ marginBottom: 16 }}>
|
||||
<button className={tab === "rules" ? "active" : ""} onClick={() => setTab("rules")}><Wand2 size={15} /> Rules</button>
|
||||
<button className={tab === "scripts" ? "active" : ""} onClick={() => setTab("scripts")}><Code size={15} /> Scripts (advanced)</button>
|
||||
</div>
|
||||
{sieve.loading && !sieve.scripts.length ? <Spinner /> : tab === "rules" ? <RulesEditor /> : <ScriptsEditor />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RulesEditor() {
|
||||
const sieve = useSieve();
|
||||
const { script, rules, content } = sieve.rules();
|
||||
const [local, setLocal] = useState<SieveRule[] | null>(null);
|
||||
const [editing, setEditing] = useState<SieveRule | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const list = local ?? rules ?? [];
|
||||
const dirty = local !== null;
|
||||
const inbox = useMail((s) => { const id = s.roleId("inbox"); return id ? s.mailboxes[id] : undefined; });
|
||||
const activeIsOther = script && script.name !== "ihasmail" && script.isActive;
|
||||
|
||||
const save = async (next: SieveRule[]) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await sieve.saveRules(next);
|
||||
setLocal(null);
|
||||
toast.success("Filters saved");
|
||||
} catch (err) {
|
||||
toast.error(`Could not save filters: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (rules === null) {
|
||||
return (
|
||||
<div className="warn-box">
|
||||
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Your active script “{script?.name}” was written by hand.</b></div>
|
||||
<p style={{ margin: "0 0 8px" }}>The visual rule editor only manages scripts it created. You can edit the script in the <b>Scripts</b> tab, or start fresh with rules (the existing script will be kept but deactivated).</p>
|
||||
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Switch to rules?", message: `“${script?.name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.`, confirmLabel: "Continue" })) void save([]); }}>Start with rules</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{activeIsOther && <div className="warn-box mb-16">Another script (“{script?.name}”) is active. Saving rules here will activate the “ihasmail” script instead.</div>}
|
||||
{list.length === 0 && <div className="empty" style={{ padding: 32 }}><Wand2 size={32} /><h3>No filters yet</h3><p>Create a rule to move newsletters to a folder, flag important senders, or forward mail.</p></div>}
|
||||
{list.map((r, i) => (
|
||||
<div key={r.id} className={`rule-card ${r.enabled ? "" : "disabled"}`}>
|
||||
<div className="row">
|
||||
<Switch checked={r.enabled} onChange={(v) => setLocal(list.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} />
|
||||
<div className="grow" style={{ cursor: "pointer", minWidth: 0 }} onClick={() => setEditing(r)}>
|
||||
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
||||
<div className="hint truncate">{describeRule(r)}</div>
|
||||
</div>
|
||||
<button className="icon-btn sm" disabled={i === 0} aria-label="Move up" onClick={() => { const n = [...list]; [n[i - 1], n[i]] = [n[i]!, n[i - 1]!]; setLocal(n); }}><ArrowUp size={16} /></button>
|
||||
<button className="icon-btn sm" disabled={i === list.length - 1} aria-label="Move down" onClick={() => { const n = [...list]; [n[i + 1], n[i]] = [n[i]!, n[i + 1]!]; setLocal(n); }}><ArrowDown size={16} /></button>
|
||||
<button className="icon-btn sm danger" aria-label="Delete rule" onClick={() => setLocal(list.filter((x) => x.id !== r.id))}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="btn" onClick={() => setEditing(newRule())}><Plus size={16} /> New rule</button>
|
||||
<span className="spacer" />
|
||||
{dirty && <button className="btn btn-ghost" onClick={() => setLocal(null)}>Discard changes</button>}
|
||||
<button className="btn btn-primary" disabled={!dirty || saving} onClick={() => void save(list)}>{saving ? "Saving…" : "Save filters"}</button>
|
||||
</div>
|
||||
{content && (
|
||||
<details style={{ marginTop: 20 }}>
|
||||
<summary className="hint" style={{ cursor: "pointer" }}>Preview generated Sieve script</summary>
|
||||
<pre className="code" style={{ minHeight: 120, marginTop: 8 }}>{rulesToSieve(list)}</pre>
|
||||
</details>
|
||||
)}
|
||||
{editing && (
|
||||
<RuleDialog
|
||||
rule={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
applyMailbox={inbox ? { id: inbox.id, name: inbox.name } : null}
|
||||
onSave={(r, applyNow) => {
|
||||
const exists = list.some((x) => x.id === r.id);
|
||||
const next = exists ? list.map((x) => (x.id === r.id ? r : x)) : [...list, r];
|
||||
setEditing(null);
|
||||
if (applyNow && inbox) {
|
||||
// Save immediately so the rule is live, then apply it to the Inbox.
|
||||
setLocal(null);
|
||||
void saveAndApply(r, next.filter((x) => x.id !== r.id), inbox.id);
|
||||
} else setLocal(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptsEditor() {
|
||||
const sieve = useSieve();
|
||||
const [sel, setSel] = useState<SieveScript | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
|
||||
const open = async (s: SieveScript | null) => {
|
||||
setSel(s);
|
||||
setValidation(null);
|
||||
if (s) {
|
||||
setName(s.name);
|
||||
setContent(await sieve.getContent(s.id));
|
||||
} else {
|
||||
setName("");
|
||||
setContent('require ["fileinto"];\n\n');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (activate: boolean) => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Script name is required");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const err = await sieve.validate(content);
|
||||
setValidation(err);
|
||||
if (err) {
|
||||
toast.error("Script has errors");
|
||||
return;
|
||||
}
|
||||
await sieve.saveScript(sel?.id ?? null, name.trim(), content, activate);
|
||||
toast.success("Script saved");
|
||||
setSel(null);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sel !== null || name !== "" || content !== "") {
|
||||
if (sel !== null || name !== "" || content !== "") {
|
||||
return (
|
||||
<div>
|
||||
<div className="field"><label>Script name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={Boolean(sel)} /></div>
|
||||
<div className="field">
|
||||
<label>Sieve source</label>
|
||||
<textarea className="code" value={content} onChange={(e) => setContent(e.target.value)} spellCheck={false} style={{ minHeight: 320 }} />
|
||||
</div>
|
||||
{validation && <div className="error-box mb-16">{validation}</div>}
|
||||
<div className="row">
|
||||
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>Cancel</button>
|
||||
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success("Script is valid"); }}><Play size={14} /> Validate</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn" disabled={busy} onClick={() => void save(false)}>Save</button>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>Save & activate</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="hint">Advanced: manage raw Sieve scripts. Only one script can be active at a time.</p>
|
||||
{sieve.scripts.map((s) => (
|
||||
<div key={s.id} className="card">
|
||||
<div className="card-head">
|
||||
<h3>{s.name} {s.isActive && <span className="tag" style={{ background: "var(--success)" }}>active</span>}</h3>
|
||||
<button className="btn btn-sm" onClick={() => void open(s)}>Edit</button>
|
||||
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button>
|
||||
<button className="icon-btn sm danger" aria-label="Delete script" onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={async () => { const n = await promptDialog({ title: "New script", placeholder: "Script name" }); if (n) { setName(n); setContent('require ["fileinto"];\n\n'); } }}><Plus size={16} /> New script</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Eye, EyeOff, Folder, Pencil, Plus, Share2, Trash2, Inbox } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
export function FoldersSettings() {
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const mailboxPath = useMail((s) => s.mailboxPath);
|
||||
const [share, setShare] = useState<Mailbox | null>(null);
|
||||
const list = useMemo(() => Object.values(mailboxes).map((m) => ({ m, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);
|
||||
const quotas = useMail((s) => s.quotas);
|
||||
const q = quotas.find((x) => x.resourceType === "octets");
|
||||
|
||||
const create = async () => {
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for subfolders, e.g. Work/Invoices)" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const parts = name.split("/").map((p) => p.trim()).filter(Boolean);
|
||||
let parentId: string | null = null;
|
||||
for (const part of parts) {
|
||||
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
|
||||
parentId = existing ? existing.id : await useMail.getState().createMailbox(part, parentId);
|
||||
}
|
||||
toast.success("Folder created");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Folders</h1>
|
||||
<p className="lead">Create, rename, hide and share folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
|
||||
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
|
||||
<table className="sessions-table">
|
||||
<thead><tr><th>Folder</th><th>Messages</th><th>Unread</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{list.map(({ m, path }) => (
|
||||
<tr key={m.id}>
|
||||
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{path}</span>{!m.isSubscribed && <span className="badge muted">hidden</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
|
||||
<td>{m.totalEmails.toLocaleString()}</td>
|
||||
<td>{m.unreadEmails.toLocaleString()}</td>
|
||||
<td>
|
||||
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
|
||||
<button className="icon-btn sm" title="Rename" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
|
||||
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
<button className="icon-btn sm" title="Share" onClick={() => setShare(m)}><Share2 size={16} /></button>
|
||||
<button className="icon-btn sm danger" title="Delete" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{share && <ShareDialog kind="Mailbox" id={share.id} name={share.name} shareWith={share.shareWith ?? null} onClose={() => setShare(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function GeneralSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
const reset = useSettings((st) => st.reset);
|
||||
const exportJson = useSettings((st) => st.exportJson);
|
||||
const importJson = useSettings((st) => st.importJson);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>General</h1>
|
||||
<p className="lead">Reading, sending and list behaviour. Settings are stored in this browser.</p>
|
||||
|
||||
<h2>Reading</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Reading pane</label>
|
||||
<select className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}>
|
||||
<option value="right">Right of the list</option>
|
||||
<option value="bottom">Below the list</option>
|
||||
<option value="off">Off (open messages full width)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mark as read</label>
|
||||
<select className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
|
||||
<option value="0">Immediately when opened</option>
|
||||
<option value="2">After 2 seconds</option>
|
||||
<option value="5">After 5 seconds</option>
|
||||
<option value="-1">Never automatically</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>After archiving or deleting</label>
|
||||
<select className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}>
|
||||
<option value="list">Go back to the list</option>
|
||||
<option value="older">Open the next (older) conversation</option>
|
||||
<option value="newer">Open the previous (newer) conversation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Remote images</label>
|
||||
<select className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
|
||||
<option value="ask">Ask before showing (recommended)</option>
|
||||
<option value="contacts">Show automatically from my contacts</option>
|
||||
<option value="always">Always show</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label="Conversation view" hint="Group messages from the same thread together." />
|
||||
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label="Show message snippets" hint="Preview the first line of each message in the list." />
|
||||
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label="Show sender avatars" />
|
||||
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label="Confirm before deleting" />
|
||||
|
||||
<h2>Composing</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Default format</label>
|
||||
<select className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}>
|
||||
<option value="html">Rich text (HTML)</option>
|
||||
<option value="text">Plain text</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Undo send window</label>
|
||||
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
|
||||
<option value="0">Off</option>
|
||||
<option value="5">5 seconds</option>
|
||||
<option value="8">8 seconds</option>
|
||||
<option value="15">15 seconds</option>
|
||||
<option value="30">30 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label="Quote original message in replies" />
|
||||
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label="Place signature above quoted text" />
|
||||
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label="Attachment reminder" hint="Warn when the message mentions an attachment but none is attached." />
|
||||
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label="Always request read receipts" />
|
||||
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label="Spell check while typing" />
|
||||
|
||||
<h2>Locale</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Time zone</label>
|
||||
<select className="select" value={s.timeZone ?? ""} onChange={(e) => update({ timeZone: e.target.value || null })}>
|
||||
<option value="">Browser default ({browserTimeZone})</option>
|
||||
{listTimeZones().map((tz) => <option key={tz} value={tz}>{tz}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Week starts on</label>
|
||||
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
|
||||
<option value="1">Monday</option>
|
||||
<option value="0">Sunday</option>
|
||||
<option value="6">Saturday</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Backup</h2>
|
||||
<div className="row wrap">
|
||||
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>Export settings</button>
|
||||
<label className="btn">
|
||||
Import settings
|
||||
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? "Settings imported" : "Invalid settings file"); e.target.value = ""; }} />
|
||||
</label>
|
||||
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>Reset to defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Trash2, Star } from "lucide-react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import type { Identity } from "@/jmap/types";
|
||||
import { Dialog, confirmDialog } from "@/ui/dialog";
|
||||
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { parseAddressList, formatAddressList } from "@/lib/address";
|
||||
import { htmlToText } from "@/lib/text";
|
||||
import { sanitizeEditorHtml } from "@/lib/html";
|
||||
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
|
||||
import { buildMarkerSignature, compactHtml, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
|
||||
|
||||
export function IdentitiesSettings() {
|
||||
const identities = useMail((s) => s.identities);
|
||||
const load = useMail((s) => s.loadIdentities);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
const setDefault = useMail((s) => s.setDefaultIdentity);
|
||||
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
|
||||
const [editing, setEditing] = useState<Partial<Identity> | null>(null);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Identities & signatures</h1>
|
||||
<p className="lead">Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.</p>
|
||||
{identities.map((i) => (
|
||||
<div key={i.id} className="card clickable" onClick={() => setEditing(i)}>
|
||||
<div className="card-head">
|
||||
<h3>{i.name ? `${i.name} <${i.email}>` : i.email} {i.id === defaultId && <span className="tag" style={{ background: "var(--accent)", color: "var(--accent-fg)", marginLeft: 6 }}>Default</span>}</h3>
|
||||
{i.id !== defaultId && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
|
||||
)}
|
||||
{i.mayDelete && (
|
||||
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
)}
|
||||
</div>
|
||||
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
|
||||
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
|
||||
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
|
||||
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; onClose: () => void }) {
|
||||
const [name, setName] = useState(identity.name ?? "");
|
||||
const [email, setEmail] = useState(identity.email ?? "");
|
||||
const [replyTo, setReplyTo] = useState(formatAddressList(identity.replyTo));
|
||||
const [html, setHtml] = useState(identity.htmlSignature || (identity.textSignature ? identity.textSignature.replace(/\n/g, "<br>") : ""));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ref = useRef<RichEditorHandle>(null);
|
||||
const compact = compactHtml(sanitizeEditorHtml(html));
|
||||
const sigLen = compact.length;
|
||||
const tooLong = sigLen > SIGNATURE_LIMIT || htmlToText(compact).length > SIGNATURE_LIMIT;
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
// 1) pasted pictures → stored files, 2) strip cruft, 3) fall back to a stored full copy.
|
||||
const externalized = await externalizeDataImages(sanitizeEditorHtml(html));
|
||||
const clean = compactHtml(externalized);
|
||||
let htmlSignature = clean;
|
||||
let textSignature = htmlToText(clean);
|
||||
if (clean.length > SIGNATURE_LIMIT || textSignature.length > SIGNATURE_LIMIT) {
|
||||
const blobId = await storeSignatureHtml(clean);
|
||||
({ htmlSignature, textSignature } = buildMarkerSignature(blobId, clean));
|
||||
}
|
||||
const patch: Partial<Identity> = {
|
||||
name,
|
||||
replyTo: replyTo.trim() ? parseAddressList(replyTo) : null,
|
||||
htmlSignature,
|
||||
textSignature,
|
||||
};
|
||||
if (!identity.id) patch.email = email.trim();
|
||||
await useMail.getState().saveIdentity(identity.id ?? null, patch);
|
||||
toast.success("Identity saved");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Display name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||
<div className="field"><label>Email address</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="field"><label>Reply-To (optional)</label><input className="input" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="[email protected]" /><span className="hint">Replies to mail sent from this identity go here instead of the From address.</span></div>
|
||||
<div className="field">
|
||||
<label>Signature</label>
|
||||
<div style={{ border: `1px solid ${tooLong ? "var(--danger)" : "var(--border-strong)"}`, borderRadius: 8, minHeight: 180, display: "flex", flexDirection: "column" }}>
|
||||
<RichEditor ref={ref} html={html} onChange={setHtml} placeholder="Your signature…" showToolbar imageUpload={uploadSignatureImage} />
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span className="hint">Images are stored in your Files (folder “ihasmail”) and embedded when you send.</span>
|
||||
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
|
||||
</div>
|
||||
{tooLong && <div className="warn-box mt-8">This signature is larger than the server's {SIGNATURE_LIMIT}-character limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.</div>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
|
||||
import { promptDialog } from "@/ui/dialog";
|
||||
|
||||
export function LabelsSettings() {
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
const update = useSettings((s) => s.update);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const add = async () => {
|
||||
const name = await promptDialog({ title: "New label", placeholder: "Label name" });
|
||||
if (!name?.trim()) return;
|
||||
const keyword = name.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
|
||||
if (labels.some((l) => l.keyword === keyword)) return;
|
||||
update({ labels: [...labels, { keyword, name: name.trim(), color: CALENDAR_COLORS[labels.length % CALENDAR_COLORS.length]! }] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Labels</h1>
|
||||
<p className="lead">Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.</p>
|
||||
{labels.map((l) => (
|
||||
<div key={l.keyword} className="card">
|
||||
<div className="card-head">
|
||||
<span className="label-dot" style={{ background: l.color, width: 14, height: 14 }} />
|
||||
{editing === l.keyword ? (
|
||||
<input className="input sm" autoFocus defaultValue={l.name} onBlur={(e) => { update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, name: e.target.value || x.name } : x)) }); setEditing(null); }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} style={{ width: 240 }} />
|
||||
) : (
|
||||
<h3 style={{ cursor: "text" }} onClick={() => setEditing(l.keyword)}>{l.name} <span className="hint" style={{ fontWeight: 400 }}>({l.keyword})</span></h3>
|
||||
)}
|
||||
<button className="icon-btn sm danger" aria-label="Delete label" onClick={() => update({ labels: labels.filter((x) => x.keyword !== l.keyword) })}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ColorSwatches value={l.color} onChange={(c) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, color: c } : x)) })} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={() => void add()}><Plus size={16} /> New label</button>
|
||||
<p className="hint mt-8">Tip: press <kbd className="kbd">l</kbd> on a conversation to apply labels. Search with <code>label:name</code>.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
export function NotificationsSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
const pushConnected = useSession((st) => st.pushConnected);
|
||||
const [perm, setPerm] = useState<NotificationPermission | "unsupported">("Notification" in window ? Notification.permission : "unsupported");
|
||||
useEffect(() => {
|
||||
if ("Notification" in window) setPerm(Notification.permission);
|
||||
}, [s.desktopNotifications]);
|
||||
return (
|
||||
<div>
|
||||
<h1>Notifications</h1>
|
||||
<p className="lead">Live updates are delivered via JMAP push ({pushConnected ? "connected" : "reconnecting…"}).</p>
|
||||
<Switch
|
||||
checked={s.desktopNotifications}
|
||||
onChange={async (v) => {
|
||||
if (v) {
|
||||
const p = await requestNotificationPermission();
|
||||
setPerm(p);
|
||||
if (p !== "granted") return;
|
||||
}
|
||||
update({ desktopNotifications: v });
|
||||
}}
|
||||
label="Desktop notifications for new mail"
|
||||
hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."}
|
||||
disabled={perm === "denied" || perm === "unsupported"}
|
||||
/>
|
||||
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
|
||||
<div className="row mt-16">
|
||||
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>
|
||||
</div>
|
||||
<p className="hint mt-8">The tab title and favicon always show your unread Inbox count.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { HEADER_CHOICES, HEADER_OPS, type SieveAction, type SieveRule, type SieveTest } from "@/lib/sieve";
|
||||
import { Dialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { Id } from "@/jmap/types";
|
||||
|
||||
export interface RuleDialogProps {
|
||||
rule: SieveRule;
|
||||
onClose: () => void;
|
||||
/** Called with the rule and whether the user asked to apply it to existing messages now. */
|
||||
onSave: (r: SieveRule, applyNow: boolean) => void;
|
||||
/** When set, offers "Also apply to existing messages in <folder>". */
|
||||
applyMailbox?: { id: Id; name: string } | null;
|
||||
title?: string;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
export function RuleDialog({ rule, onClose, onSave, applyMailbox, title, saveLabel }: RuleDialogProps) {
|
||||
const [r, setR] = useState<SieveRule>(rule);
|
||||
const [applyNow, setApplyNow] = useState(Boolean(applyMailbox));
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const mailboxPath = useMail((s) => s.mailboxPath);
|
||||
const folders = useMemo(() => Object.values(mailboxes).map((m) => ({ id: m.id, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);
|
||||
const setTest = (i: number, t: SieveTest) => setR({ ...r, tests: r.tests.map((x, j) => (j === i ? t : x)) });
|
||||
const setAction = (i: number, a: SieveAction) => setR({ ...r, actions: r.actions.map((x, j) => (j === i ? a : x)) });
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? "New rule" : "Edit rule")} size="lg" footer={<>
|
||||
{applyMailbox && (
|
||||
<label className="check left" style={{ marginRight: "auto" }}>
|
||||
<input type="checkbox" checked={applyNow} onChange={(e) => setApplyNow(e.target.checked)} />
|
||||
<span>Also apply to existing messages in <b>{applyMailbox.name}</b></span>
|
||||
</label>
|
||||
)}
|
||||
<button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
|
||||
<div className="field"><label>Rule name</label><input className="input" value={r.name} onChange={(e) => setR({ ...r, name: e.target.value })} autoFocus /></div>
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<span className="label">When</span>
|
||||
<select className="select" style={{ width: "auto" }} value={r.join} onChange={(e) => setR({ ...r, join: e.target.value as "allof" | "anyof" })}>
|
||||
<option value="allof">all of the following match</option>
|
||||
<option value="anyof">any of the following match</option>
|
||||
</select>
|
||||
</div>
|
||||
{r.tests.map((t, i) => (
|
||||
<div key={i} className="rule-row">
|
||||
<select className="select" value={t.type === "true" ? "true" : t.type === "size" ? "size" : t.type === "body" ? "body" : t.type === "address" ? "address" : HEADER_CHOICES.some((h) => h.value === t.header) ? t.header : "__custom__"} onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "size") setTest(i, { type: "size", op: "over", value: 1024 * 1024 });
|
||||
else if (v === "body") setTest(i, { type: "body", op: "contains", value: "" });
|
||||
else if (v === "true") setTest(i, { type: "true" });
|
||||
else if (v === "address") setTest(i, { type: "address", header: "from", part: "domain", op: "is", value: "" });
|
||||
else setTest(i, { type: "header", header: v === "__custom__" ? "" : v, op: "contains", value: "" });
|
||||
}}>
|
||||
{HEADER_CHOICES.map((h) => <option key={h.value} value={h.value}>{h.label}</option>)}
|
||||
<option value="address">Sender domain</option>
|
||||
<option value="size">Message size</option>
|
||||
<option value="body">Body text</option>
|
||||
<option value="true">Always (all messages)</option>
|
||||
</select>
|
||||
{t.type === "header" && !HEADER_CHOICES.some((h) => h.value === t.header && h.value !== "__custom__") ? (
|
||||
<input className="input" placeholder="Header name" value={t.header} onChange={(e) => setTest(i, { ...t, header: e.target.value })} />
|
||||
) : t.type === "size" ? (
|
||||
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "over" | "under" })}><option value="over">is larger than</option><option value="under">is smaller than</option></select>
|
||||
) : t.type === "body" ? (
|
||||
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">contains</option><option value="notcontains">does not contain</option></select>
|
||||
) : t.type === "true" ? <span /> : (
|
||||
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as SieveTest extends { op: infer O } ? O : never })}>
|
||||
{HEADER_OPS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{t.type === "size" ? (
|
||||
<div className="row"><input className="input" type="number" min={1} value={Math.round(t.value / 1024)} onChange={(e) => setTest(i, { ...t, value: Number(e.target.value) * 1024 })} /><span className="muted">KB</span></div>
|
||||
) : t.type === "true" ? <span /> : t.type === "header" && (t.op === "exists" || t.op === "notexists") ? <span /> : (
|
||||
<input className="input" placeholder={t.type === "address" ? "example.com" : "value"} value={(t as { value: string }).value} onChange={(e) => setTest(i, { ...t, value: e.target.value } as SieveTest)} />
|
||||
)}
|
||||
<button className="icon-btn sm danger" aria-label="Remove condition" onClick={() => setR({ ...r, tests: r.tests.filter((_, j) => j !== i) })} disabled={r.tests.length <= 1}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, tests: [...r.tests, { type: "header", header: "subject", op: "contains", value: "" }] })}><Plus size={14} /> Add condition</button>
|
||||
|
||||
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">Then</span></div>
|
||||
{r.actions.map((a, i) => (
|
||||
<div key={i} className="rule-row actions">
|
||||
<select className="select" value={a.type} onChange={(e) => {
|
||||
const v = e.target.value as SieveAction["type"];
|
||||
const next: SieveAction = v === "fileinto" ? { type: "fileinto", mailbox: folders[0]?.path ?? "INBOX" } : v === "redirect" ? { type: "redirect", address: "" } : v === "reject" ? { type: "reject", reason: "" } : v === "addflag" ? { type: "addflag", flag: "" } : ({ type: v } as SieveAction);
|
||||
setAction(i, next);
|
||||
}}>
|
||||
<option value="fileinto">Move to folder</option>
|
||||
<option value="markread">Mark as read</option>
|
||||
<option value="flag">Star</option>
|
||||
<option value="addflag">Add label / keyword</option>
|
||||
<option value="redirect">Forward to</option>
|
||||
<option value="keep">Keep in Inbox</option>
|
||||
<option value="discard">Delete</option>
|
||||
<option value="reject">Reject with message</option>
|
||||
<option value="stop">Stop processing more rules</option>
|
||||
</select>
|
||||
{a.type === "fileinto" ? (
|
||||
<div className="row">
|
||||
<select
|
||||
className="select"
|
||||
value={a.mailbox}
|
||||
onChange={async (e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "__new__") {
|
||||
// Create a folder on the fly ("Parent/Child" creates nested folders).
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for a subfolder, e.g. Work/Invoices)" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const mail = useMail.getState();
|
||||
const parts = name.split("/").map((x) => x.trim()).filter(Boolean);
|
||||
let parentId: string | null = null;
|
||||
for (const part of parts) {
|
||||
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
|
||||
parentId = existing ? existing.id : await mail.createMailbox(part, parentId);
|
||||
}
|
||||
const path = useMail.getState().mailboxPath(parentId!);
|
||||
setAction(i, { ...a, mailbox: path, mailboxId: parentId! });
|
||||
toast.success(`Folder “${path}” created`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setAction(i, { ...a, mailbox: v, mailboxId: folders.find((f) => f.path === v)?.id });
|
||||
}}
|
||||
>
|
||||
{folders.map((f) => <option key={f.id} value={f.path}>{f.path}</option>)}
|
||||
{!folders.some((f) => f.path === a.mailbox) && <option value={a.mailbox}>{a.mailbox}</option>}
|
||||
<option value="__new__">+ New folder…</option>
|
||||
</select>
|
||||
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
|
||||
</div>
|
||||
) : a.type === "redirect" ? (
|
||||
<div className="row">
|
||||
<input className="input" type="email" placeholder="[email protected]" value={a.address} onChange={(e) => setAction(i, { ...a, address: e.target.value })} />
|
||||
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
|
||||
</div>
|
||||
) : a.type === "reject" ? (
|
||||
<input className="input" placeholder="Reason" value={a.reason} onChange={(e) => setAction(i, { ...a, reason: e.target.value })} />
|
||||
) : a.type === "addflag" || a.type === "setflag" || a.type === "removeflag" ? (
|
||||
<input className="input" placeholder="keyword (e.g. $important, work)" value={a.flag} onChange={(e) => setAction(i, { ...a, flag: e.target.value })} />
|
||||
) : <span />}
|
||||
<button className="icon-btn sm danger" aria-label="Remove action" onClick={() => setR({ ...r, actions: r.actions.filter((_, j) => j !== i) })} disabled={r.actions.length <= 1}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> Add action</button>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { useSession } from "@/store/session";
|
||||
import { formatFullDate } from "@/lib/format";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export function SecuritySettings() {
|
||||
const [rows, setRows] = useState<SessionRow[] | null>(null);
|
||||
const [current, setCurrent] = useState<string>("");
|
||||
const session = useSession((s) => s.session);
|
||||
const logout = useSession((s) => s.logout);
|
||||
const load = () => apiFetch<{ current: string; sessions: SessionRow[] }>("/api/auth/sessions").then((r) => { setRows(r.sessions); setCurrent(r.current); }).catch(() => setRows([]));
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
<h1>Security & sessions</h1>
|
||||
<p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p>
|
||||
<h2>Active webmail sessions</h2>
|
||||
{rows === null ? <p className="hint">Loading…</p> : (
|
||||
<table className="sessions-table">
|
||||
<thead><tr><th>Device</th><th>IP</th><th>Last active</th><th>Expires</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>this device</span>}</td>
|
||||
<td className="mono small">{r.ip}</td>
|
||||
<td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td>
|
||||
<td>{formatFullDate(new Date(r.expiresAt).toISOString())}{r.remember ? " (remembered)" : ""}</td>
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="row mt-16">
|
||||
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>Sign out all other sessions</button>
|
||||
<button className="btn btn-ghost" onClick={() => void logout()}>Sign out here</button>
|
||||
</div>
|
||||
<h2>Password & two-factor</h2>
|
||||
<p className="hint">Password changes, app passwords and 2FA are managed by your mail administrator or via Stalwart's self-service portal.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortUa(ua: string): string {
|
||||
const browser = /Firefox\/(\d+)/.exec(ua) ? `Firefox ${/Firefox\/(\d+)/.exec(ua)![1]}` : /Edg\/(\d+)/.exec(ua) ? `Edge ${/Edg\/(\d+)/.exec(ua)![1]}` : /Chrome\/(\d+)/.exec(ua) ? `Chrome ${/Chrome\/(\d+)/.exec(ua)![1]}` : /Safari\/(\d+)/.exec(ua) ? "Safari" : "Browser";
|
||||
const os = /Windows/.test(ua) ? "Windows" : /Android/.test(ua) ? "Android" : /iPhone|iPad/.test(ua) ? "iOS" : /Mac OS/.test(ua) ? "macOS" : /Linux/.test(ua) ? "Linux" : "";
|
||||
return `${browser}${os ? ` on ${os}` : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { lazy, Suspense, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { ArrowLeft, Bell, Filter, Folder, Info, Keyboard, LayoutTemplate, Palette, PenLine, Plane, Settings as SettingsIcon, ShieldCheck, Tag, Users, Calendar } from "lucide-react";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { GeneralSettings } from "./GeneralSettings";
|
||||
import { AppearanceSettings } from "./AppearanceSettings";
|
||||
import { IdentitiesSettings } from "./IdentitiesSettings";
|
||||
import { FoldersSettings } from "./FoldersSettings";
|
||||
import { LabelsSettings } from "./LabelsSettings";
|
||||
import { TemplatesSettings } from "./TemplatesSettings";
|
||||
import { NotificationsSettings } from "./NotificationsSettings";
|
||||
import { SecuritySettings } from "./SecuritySettings";
|
||||
import { AboutSettings } from "./AboutSettings";
|
||||
import { ShortcutsSettings } from "./ShortcutsSettings";
|
||||
import { CalendarSettings } from "./CalendarSettings";
|
||||
|
||||
const FiltersSettings = lazy(() => import("./FiltersSettings").then((m) => ({ default: m.FiltersSettings })));
|
||||
const VacationSettings = lazy(() => import("./VacationSettings").then((m) => ({ default: m.VacationSettings })));
|
||||
|
||||
const SECTIONS: Array<{ id: string; label: string; icon: ReactNode; el: ReactNode }> = [
|
||||
{ id: "general", label: "General", icon: <SettingsIcon size={18} />, el: <GeneralSettings /> },
|
||||
{ id: "appearance", label: "Appearance", icon: <Palette size={18} />, el: <AppearanceSettings /> },
|
||||
{ id: "identities", label: "Identities & signatures", icon: <PenLine size={18} />, el: <IdentitiesSettings /> },
|
||||
{ id: "filters", label: "Filters & rules", icon: <Filter size={18} />, el: <FiltersSettings /> },
|
||||
{ id: "vacation", label: "Out of office", icon: <Plane size={18} />, el: <VacationSettings /> },
|
||||
{ id: "folders", label: "Folders", icon: <Folder size={18} />, el: <FoldersSettings /> },
|
||||
{ id: "labels", label: "Labels", icon: <Tag size={18} />, el: <LabelsSettings /> },
|
||||
{ id: "templates", label: "Templates", icon: <LayoutTemplate size={18} />, el: <TemplatesSettings /> },
|
||||
{ id: "calendar", label: "Calendar & contacts", icon: <Calendar size={18} />, el: <CalendarSettings /> },
|
||||
{ id: "notifications", label: "Notifications", icon: <Bell size={18} />, el: <NotificationsSettings /> },
|
||||
{ id: "security", label: "Security & sessions", icon: <ShieldCheck size={18} />, el: <SecuritySettings /> },
|
||||
{ id: "shortcuts", label: "Keyboard shortcuts", icon: <Keyboard size={18} />, el: <ShortcutsSettings /> },
|
||||
{ id: "about", label: "About", icon: <Info size={18} />, el: <AboutSettings /> },
|
||||
];
|
||||
|
||||
export function SettingsView({ section }: { section?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const current = SECTIONS.find((s) => s.id === section);
|
||||
return (
|
||||
<div className={`settings-layout ${section ? "section" : "root"}`}>
|
||||
<nav className="settings-nav" aria-label="Settings">
|
||||
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Settings</span></div>
|
||||
{SECTIONS.map((s) => (
|
||||
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
|
||||
{s.icon}
|
||||
<span className="nav-label">{s.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Shortcuts</span></div>
|
||||
<Link href="/contacts" className="nav-item"><Users size={18} /><span className="nav-label">Address books</span></Link>
|
||||
</nav>
|
||||
<div className="settings-content">
|
||||
{section && (
|
||||
<button className="btn btn-ghost btn-sm" style={{ marginBottom: 8, marginLeft: -8 }} onClick={() => navigate("/settings")}>
|
||||
<ArrowLeft size={16} /> All settings
|
||||
</button>
|
||||
)}
|
||||
<Suspense fallback={<Spinner />}>{current ? current.el : <GeneralSettings />}</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { client } from "@/jmap/client";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { Id, Principal } from "@/jmap/types";
|
||||
|
||||
type Kind = "Mailbox" | "Calendar" | "AddressBook";
|
||||
|
||||
const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
|
||||
Mailbox: [
|
||||
{ key: "mayReadItems", label: "Read" },
|
||||
{ key: "mayAddItems", label: "Add" },
|
||||
{ key: "mayRemoveItems", label: "Remove" },
|
||||
{ key: "maySetSeen", label: "Mark read" },
|
||||
{ key: "maySetKeywords", label: "Flag" },
|
||||
{ key: "mayCreateChild", label: "Create subfolders" },
|
||||
{ key: "mayRename", label: "Rename" },
|
||||
{ key: "mayDelete", label: "Delete" },
|
||||
{ key: "maySubmit", label: "Send" },
|
||||
],
|
||||
Calendar: [
|
||||
{ key: "mayReadFreeBusy", label: "See free/busy" },
|
||||
{ key: "mayReadItems", label: "Read events" },
|
||||
{ key: "mayWriteAll", label: "Edit all" },
|
||||
{ key: "mayWriteOwn", label: "Edit own" },
|
||||
{ key: "mayUpdatePrivate", label: "Private props" },
|
||||
{ key: "mayRSVP", label: "RSVP" },
|
||||
{ key: "mayShare", label: "Share" },
|
||||
{ key: "mayDelete", label: "Delete" },
|
||||
],
|
||||
AddressBook: [
|
||||
{ key: "mayRead", label: "Read" },
|
||||
{ key: "mayWrite", label: "Write" },
|
||||
{ key: "mayShare", label: "Share" },
|
||||
{ key: "mayDelete", label: "Delete" },
|
||||
],
|
||||
};
|
||||
|
||||
const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = {
|
||||
Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] },
|
||||
Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] },
|
||||
AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] },
|
||||
};
|
||||
|
||||
/** Share a mailbox / calendar / address book with other principals (JMAP Sharing, RFC 9670). */
|
||||
export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) {
|
||||
const principals = useContacts((s) => s.principals);
|
||||
const loadPrincipals = useContacts((s) => s.loadPrincipals);
|
||||
const [rights, setRights] = useState<Record<Id, Record<string, boolean>>>(() => ({ ...((shareWith ?? {}) as Record<Id, Record<string, boolean>>) }));
|
||||
const [pick, setPick] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
void loadPrincipals();
|
||||
}, [loadPrincipals]);
|
||||
|
||||
const available = principals.filter((p) => !rights[p.id]);
|
||||
const add = (p: Principal, preset: "reader" | "editor") => {
|
||||
const r: Record<string, boolean> = {};
|
||||
for (const k of PRESETS[kind][preset]) r[k] = true;
|
||||
setRights({ ...rights, [p.id]: r });
|
||||
setPick("");
|
||||
};
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId;
|
||||
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
toast.success("Sharing updated");
|
||||
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
|
||||
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
|
||||
if (kind === "AddressBook") void useContacts.getState().loadBooks();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={`Share “${name}”`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>Save</button></>}>
|
||||
{!principals.length ? (
|
||||
<p className="hint">No other users found in the directory, or sharing is not enabled on this server.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
|
||||
<option value="">Add a person or group…</option>
|
||||
{available.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}{p.email ? ` <${p.email}>` : ""}{p.type !== "individual" ? ` (${p.type})` : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
|
||||
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>Editor</button>
|
||||
</div>
|
||||
{Object.entries(rights).map(([pid, r]) => {
|
||||
const p = principals.find((x) => x.id === pid);
|
||||
return (
|
||||
<div key={pid} className="card">
|
||||
<div className="card-head">
|
||||
<h3>{p?.name ?? pid}{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3>
|
||||
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label="Remove"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div className="row wrap" style={{ marginTop: 8 }}>
|
||||
{RIGHTS[kind].map((rt) => (
|
||||
<label key={rt.key} className="check" style={{ padding: "2px 6px" }}>
|
||||
<input type="checkbox" checked={Boolean(r[rt.key])} onChange={(e) => setRights({ ...rights, [pid]: { ...r, [rt.key]: e.target.checked } })} />
|
||||
<span className="small">{rt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { Kbd } from "@/ui/misc";
|
||||
|
||||
export function ShortcutsSettings() {
|
||||
const list = useMemo(() => keyboard.list(), []);
|
||||
const groups = useMemo(() => {
|
||||
const g = new Map<string, typeof list>();
|
||||
for (const b of list) {
|
||||
const arr = g.get(b.group) ?? [];
|
||||
arr.push(b);
|
||||
g.set(b.group, arr);
|
||||
}
|
||||
return [...g.entries()];
|
||||
}, [list]);
|
||||
return (
|
||||
<div>
|
||||
<h1>Keyboard shortcuts</h1>
|
||||
<p className="lead">Gmail-style shortcuts are always on. Press <kbd className="kbd">?</kbd> anywhere to see this list.</p>
|
||||
<div className="shortcut-grid">
|
||||
{groups.map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<h3>{group}</h3>
|
||||
{items.map((b) => (
|
||||
<div key={b.keys} className="shortcut-row"><span>{b.description}</span><Kbd keys={b.keys} /></div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{!groups.length && <p className="hint">Open the Mail view to see all shortcuts.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useSettings, type Template } from "@/store/settings";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { RichEditor } from "../compose/RichEditor";
|
||||
import { htmlToText } from "@/lib/text";
|
||||
|
||||
export function TemplatesSettings() {
|
||||
const templates = useSettings((s) => s.settings.templates);
|
||||
const update = useSettings((s) => s.update);
|
||||
const [editing, setEditing] = useState<Template | null>(null);
|
||||
return (
|
||||
<div>
|
||||
<h1>Templates</h1>
|
||||
<p className="lead">Canned responses you can insert into any message from the composer's template button.</p>
|
||||
{templates.map((t) => (
|
||||
<div key={t.id} className="card clickable" onClick={() => setEditing(t)}>
|
||||
<div className="card-head">
|
||||
<h3>{t.name}</h3>
|
||||
<button className="icon-btn sm danger" aria-label="Delete template" onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{t.subject && <div className="hint">Subject: {t.subject}</div>}
|
||||
<div className="hint truncate">{htmlToText(t.html).slice(0, 140)}</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> New template</button>
|
||||
{editing && (
|
||||
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>Cancel</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>Save</button></>}>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Name</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
|
||||
<div className="field"><label>Subject (optional)</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Body</label>
|
||||
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 8, minHeight: 200, display: "flex", flexDirection: "column" }}>
|
||||
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder="Template text…" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
|
||||
import { client, CAP } from "@/jmap/client";
|
||||
|
||||
export function VacationSettings() {
|
||||
const vacation = useMail((s) => s.vacation);
|
||||
const load = useMail((s) => s.loadVacation);
|
||||
const save = useMail((s) => s.saveVacation);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const available = client.hasCapability(CAP.vacation);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
if (!vacation) return;
|
||||
setEnabled(vacation.isEnabled);
|
||||
setSubject(vacation.subject ?? "");
|
||||
setBody(vacation.textBody ?? "");
|
||||
setFrom(vacation.fromDate ? toInputDateTime(new Date(vacation.fromDate)) : "");
|
||||
setTo(vacation.toDate ? toInputDateTime(new Date(vacation.toDate)) : "");
|
||||
}, [vacation]);
|
||||
|
||||
if (!available) return <div><h1>Out of office</h1><p className="lead">Vacation responses are not available for this account.</p></div>;
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await save({
|
||||
isEnabled: enabled,
|
||||
subject: subject || null,
|
||||
textBody: body || null,
|
||||
htmlBody: null,
|
||||
fromDate: from ? toUTCDate(fromInputDateTime(from)) : null,
|
||||
toDate: to ? toUTCDate(fromInputDateTime(to)) : null,
|
||||
});
|
||||
toast.success(enabled ? "Auto-reply is on" : "Auto-reply saved");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Out of office</h1>
|
||||
<p className="lead">Automatically reply to people who email you while you're away. Each sender gets at most one reply.</p>
|
||||
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
|
||||
<div className="field-row mt-16">
|
||||
<div className="field"><label>Starts (optional)</label><input className="input" type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} /></div>
|
||||
<div className="field"><label>Ends (optional)</label><input className="input" type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="field"><label>Subject</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Out of office" /></div>
|
||||
<div className="field"><label>Message</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Thanks for your message. I'm away until … and will reply when I'm back." /></div>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={() => void submit()}>Save</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user