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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user