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