attachments preview overlay, docx/xlsx/odf rendering, composer drag-drop & undo-send UX

Frontend:
- Full-screen attachment preview overlay (AttachmentPreview) for image/pdf/txt
  plus office docs: .docx via docx-preview (rendered live into the sandboxed
  iframe so header/footer VML images survive), .xlsx via xlsx-preview, and
  .odt/.ods/.odp/.odg via a JSZip+DOMParser ODF walker (officeConvert).
- Type-aware attachment icons (green spreadsheet, blue document, red PDF,
  image/archive); new document/image/spreadsheet/print SVGs.
- Composer: drag-drop attachments (body drop inserts images inline, else
  attaches); inserted images are selectable/resizable/deletable (RichTextEditor).
- Undo-send: "Sending…" banner with live "N seconds to Undo" countdown.
- Search result list: pin/archive/delete update correctly; grey-out only for
  search hits; scoped "Search in Trash/Spam".

Backend:
- Search: prefix (search-as-you-type) matching, email-address search, and
  address-first result ordering; Trash/Junk excluded with scoped folder search.
- Attachments: RFC 6266 Content-Disposition for non-ASCII filenames; one-time
  inline image loading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
2026-07-26 16:15:49 +02:00
co-authored by Claude Opus 4.8
parent c023cfa15e
commit dd0b089049
22 changed files with 2726 additions and 82 deletions
+124 -22
View File
@@ -142,7 +142,17 @@ export function Composer({
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
const [attachError, setAttachError] = useState("");
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
const [undo, setUndo] = useState<{ outboxId: string; until: number } | null>(null);
const [undoLeft, setUndoLeft] = useState(0);
// Tick the undo countdown while the window is open.
useEffect(() => {
if (!undo) return;
const id = window.setInterval(
() => setUndoLeft(Math.max(0, Math.ceil((undo.until - Date.now()) / 1000))),
500,
);
return () => window.clearInterval(id);
}, [undo]);
const [showClosePrompt, setShowClosePrompt] = useState(false);
const draftId = useRef<string | null>(init.draftId ?? null);
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
@@ -224,7 +234,7 @@ export function Composer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addFiles = async (files: FileList) => {
const addFiles = async (files: FileList | File[]) => {
markDirty();
setAttachError("");
for (const file of Array.from(files)) {
@@ -246,6 +256,77 @@ export function Composer({
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// Drag & drop: files dropped on the writing area insert inline (images) or
// attach (other types); dropped anywhere above it, everything attaches.
const editorAreaRef = useRef<HTMLDivElement>(null);
const [dragKind, setDragKind] = useState<null | "body" | "attach">(null);
const readDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(r.error);
r.readAsDataURL(file);
});
const setCaretToPoint = (x: number, y: number) => {
const d = document as Document & {
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
};
let range: Range | null = null;
if (d.caretRangeFromPoint) range = d.caretRangeFromPoint(x, y);
else if (d.caretPositionFromPoint) {
const p = d.caretPositionFromPoint(x, y);
if (p) {
range = document.createRange();
range.setStart(p.offsetNode, p.offset);
range.collapse(true);
}
}
if (range && editorAreaRef.current?.contains(range.startContainer)) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
};
const onDragOver = (e: React.DragEvent) => {
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
e.preventDefault();
setDragKind(editorAreaRef.current?.contains(e.target as Node) ? "body" : "attach");
};
const onDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragKind(null);
};
const onDrop = async (e: React.DragEvent) => {
const files = e.dataTransfer.files;
if (!files || !files.length) return;
e.preventDefault();
const inBody = editorAreaRef.current?.contains(e.target as Node);
setDragKind(null);
if (!inBody) {
void addFiles(files);
return;
}
setCaretToPoint(e.clientX, e.clientY);
const others: File[] = [];
for (const file of Array.from(files)) {
if (file.type.startsWith("image/")) {
try {
editorRef.current?.insertHtml(`<img src="${await readDataUrl(file)}" style="max-width:100%">`);
markDirty();
} catch {
others.push(file); // fall back to attaching if the read fails
}
} else {
others.push(file);
}
}
if (others.length) void addFiles(others);
};
// The editor's seed content. New messages get the account signature (2 blank
// lines above it); replies keep the quoted original in a marked block so the
// signature can later be inserted above it.
@@ -325,8 +406,12 @@ export function Composer({
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
else await api.updateDraft(draftId.current, payload());
const res = await api.send(draftId.current);
setUndo({ outboxId: res.outbox_id });
undoTimer.current = window.setTimeout(onClose, 10000);
// Drive the countdown + auto-close off the server's undo deadline.
const until = new Date(res.undo_until).getTime();
const remaining = Math.max(0, until - Date.now());
setUndo({ outboxId: res.outbox_id, until });
setUndoLeft(Math.ceil(remaining / 1000));
undoTimer.current = window.setTimeout(onClose, remaining);
} catch (e) {
sentRef.current = false;
setStatus("error");
@@ -403,22 +488,37 @@ export function Composer({
if (undo) {
return (
<div className="m-3 flex items-center justify-between rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg">
<span>{t("compose.sent")}</span>
<button className="font-medium text-app-accent" onClick={doUndo}>
{t("compose.undo")}
</button>
<span>{t("compose.sending")}</span>
<span className="flex items-center gap-1.5">
{undoLeft > 0 && (
<span className="tabular-nums text-app-surface/70">
{t("compose.undoIn", { count: undoLeft })}
</span>
)}
<button className="font-medium text-app-accent hover:underline" onClick={doUndo}>
{t("compose.undo")}
</button>
</span>
</div>
);
}
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
fullWindow
? "h-full"
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}`
}`}
>
{dragKind && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-app-accent bg-app-accent/10 text-sm font-medium text-app-accent">
{dragKind === "body" ? t("compose.dropBody") : t("compose.dropAttach")}
</div>
)}
{showClosePrompt && (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
@@ -672,20 +772,22 @@ export function Composer({
</div>
)}
{/* Body */}
{bodyReady ? (
<RichTextEditor
ref={editorRef}
initialHtml={initialHtml}
onInput={(html) => {
markDirty();
setBodyHtml(html);
}}
showToolbar={showToolbar}
/>
) : (
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
{/* Body — wrapped so a drop here is detected as the writing area. */}
<div ref={editorAreaRef} className="flex min-h-0 flex-1 flex-col">
{bodyReady ? (
<RichTextEditor
ref={editorRef}
initialHtml={initialHtml}
onInput={(html) => {
markDirty();
setBodyHtml(html);
}}
showToolbar={showToolbar}
/>
) : (
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
</div>
</div>
);
}
@@ -8,6 +8,8 @@ export interface RichTextEditorHandle {
replaceSignature: (html: string) => void;
// Insert the signature below the body but above quoted content (reply menu).
insertSignature: (html: string) => void;
// Insert HTML (e.g. a dropped inline image) at the caret, or append it.
insertHtml: (html: string) => void;
}
// A lightweight WYSIWYG editor over a contenteditable div. The DOM is the source
@@ -153,6 +155,9 @@ export const RichTextEditor = forwardRef<
const paletteRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<Record<string, boolean>>({});
const [colorMenu, setColorMenu] = useState<null | "fore" | "hilite">(null);
// Currently-selected inline image + its handle box (px, relative to the editor).
const [selImg, setSelImg] = useState<HTMLImageElement | null>(null);
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
// Seed the editable region exactly once.
useEffect(() => {
@@ -194,6 +199,19 @@ export const RichTextEditor = forwardRef<
el.insertBefore(spacerAfter, anchor);
onInput(el.innerHTML);
},
insertHtml: (html: string) => {
const el = ref.current;
if (!el || !html) return;
el.focus();
// Insert at the current caret; if that fails (no selection in the editor),
// append before the signature/quoted block.
if (!document.execCommand("insertHTML", false, html)) {
const block = document.createElement("div");
block.innerHTML = html;
el.insertBefore(block, el.querySelector(".wmc-signature, .wmc-quoted"));
}
onInput(el.innerHTML);
},
}));
// Remember the caret/selection so native pickers (color/file) that steal focus
@@ -294,6 +312,75 @@ export const RichTextEditor = forwardRef<
reader.readAsDataURL(file);
};
// ── Inline-image select / resize / delete ─────────────────────────────────
const computeBox = useCallback(() => {
const img = selImg;
const ed = ref.current;
if (!img || !ed) return null;
const ir = img.getBoundingClientRect();
const er = ed.getBoundingClientRect();
return { left: ir.left - er.left, top: ir.top - er.top, width: ir.width, height: ir.height };
}, [selImg]);
useEffect(() => setBox(computeBox()), [selImg, computeBox]);
// Reposition handles on window resize; deselect on an outside click.
useEffect(() => {
if (!selImg) return;
const onWin = () => setBox(computeBox());
const onDown = (e: MouseEvent) => {
const el = e.target as HTMLElement;
if (el === selImg || el.closest("[data-img-handle]")) return;
setSelImg(null);
};
window.addEventListener("resize", onWin);
document.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("resize", onWin);
document.removeEventListener("mousedown", onDown);
};
}, [selImg, computeBox]);
const startResize = (corner: "nw" | "ne" | "sw" | "se") => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const img = selImg;
const ed = ref.current;
if (!img || !ed) return;
const startX = e.clientX;
const startW = img.getBoundingClientRect().width;
const maxW = ed.clientWidth - 24; // minus px-3 padding
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const grow = corner === "se" || corner === "ne" ? dx : -dx;
img.style.width = `${Math.round(Math.max(24, Math.min(startW + grow, maxW)))}px`;
img.style.height = "auto";
setBox(computeBox());
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
emit();
setBox(computeBox());
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const onEditorClick = (e: React.MouseEvent) => {
const el = e.target as HTMLElement;
setSelImg(el.tagName === "IMG" ? (el as HTMLImageElement) : null);
};
const onEditorKeyDown = (e: React.KeyboardEvent) => {
if (selImg && (e.key === "Delete" || e.key === "Backspace")) {
e.preventDefault();
selImg.remove();
setSelImg(null);
emit();
}
};
const Btn = ({
onClick,
title,
@@ -452,18 +539,44 @@ export const RichTextEditor = forwardRef<
{ICONS.clear}
</Btn>
</div>
<div
ref={ref}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
data-testid="rich-editor"
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
onKeyUp={refreshActive}
onMouseUp={refreshActive}
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/>
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={ref}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
data-testid="rich-editor"
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
onKeyUp={refreshActive}
onMouseUp={refreshActive}
onClick={onEditorClick}
onKeyDown={onEditorKeyDown}
onScroll={() => setBox(computeBox())}
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/>
{selImg && box && (
<>
<div
className="pointer-events-none absolute z-10 border-2 border-app-accent"
style={{ left: box.left - 1, top: box.top - 1, width: box.width + 2, height: box.height + 2 }}
/>
{(["nw", "ne", "sw", "se"] as const).map((c) => (
<div
key={c}
data-img-handle
onMouseDown={startResize(c)}
className="absolute z-10 h-2.5 w-2.5 rounded-sm border border-white bg-app-accent shadow"
style={{
left: (c[1] === "w" ? box.left : box.left + box.width) - 5,
top: (c[0] === "n" ? box.top : box.top + box.height) - 5,
cursor: c === "nw" || c === "se" ? "nwse-resize" : "nesw-resize",
}}
/>
))}
</>
)}
</div>
</div>
);
});