diff --git a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx
index aee07617..6e5bd903 100644
--- a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx
@@ -73,27 +73,7 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st
);
};
-// Inline-image preview with a managed object URL (created/revoked with the file).
-const InlineThumb = ({ file, onRemove }: { file: File; onRemove: () => void }) => {
- const [url, setUrl] = useState('');
- useEffect(() => {
- const u = URL.createObjectURL(file);
- setUrl(u);
- return () => URL.revokeObjectURL(u);
- }, [file]);
- return (
-
- {url &&

}
-
-
- );
-};
+const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>');
export const ComposeModal = () => {
const client = useClient();
@@ -105,13 +85,29 @@ export const ComposeModal = () => {
const [bcc, setBcc] = useState('');
const [showBcc, setShowBcc] = useState(false);
const [subject, setSubject] = useState('');
- const [body, setBody] = useState('');
const [files, setFiles] = useState([]);
- const [inlineImages, setInlineImages] = useState([]);
const [dragging, setDragging] = useState(false);
+ const [bodyEmpty, setBodyEmpty] = useState(true);
const [sending, setSending] = useState(false);
const seeded = useRef(null);
const fileInput = useRef(null);
+ const editorRef = useRef(null);
+ const inlineMap = useRef(new Map()); // data-inline-id → File, for the images embedded in the body
+ const nextImgId = useRef(0);
+
+ const refreshEmpty = () => {
+ const el = editorRef.current;
+ setBodyEmpty(!el || (!el.textContent?.trim() && !el.querySelector('img')));
+ };
+
+ // Free object URLs for any images still in the editor, and clear the id→File map.
+ const revokeInline = () => {
+ editorRef.current?.querySelectorAll('img[data-inline-id]').forEach((img) => {
+ const src = (img as HTMLImageElement).src;
+ if (src.startsWith('blob:')) URL.revokeObjectURL(src);
+ });
+ inlineMap.current.clear();
+ };
useEffect(() => {
if (draft && draft !== seeded.current) {
@@ -122,9 +118,12 @@ export const ComposeModal = () => {
setBcc('');
setShowBcc(false);
setSubject(draft.subject ?? '');
- setBody(draft.body ?? '');
setFiles([]);
- setInlineImages([]);
+ inlineMap.current.clear();
+ nextImgId.current = 0;
+ // Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
+ if (editorRef.current) editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '
') : '';
+ refreshEmpty();
}
if (!draft) seeded.current = null;
}, [draft]);
@@ -132,33 +131,74 @@ export const ComposeModal = () => {
// Clipboard images often come nameless — give them a sensible filename.
const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }));
- // Attach button: everything goes as a regular attachment.
+ // Attach button (and non-image paste/drop): everything goes as a regular attachment.
const addAttachments = (incoming: FileList | File[]) => {
const list = Array.from(incoming).map(named);
if (list.length) setFiles((prev) => [...prev, ...list]);
};
- // Paste / drop: images embed inline in the body, everything else is a regular attachment.
- const routeFiles = (incoming: FileList | File[]) => {
- const list = Array.from(incoming).map(named);
- const imgs = list.filter((f) => f.type.startsWith('image/'));
- const rest = list.filter((f) => !f.type.startsWith('image/'));
- if (imgs.length) setInlineImages((prev) => [...prev, ...imgs]);
- if (rest.length) setFiles((prev) => [...prev, ...rest]);
+
+ // Insert an image into the body at the given range (or the current caret, or the end).
+ const insertImage = (file: File, at?: Range | null) => {
+ const el = editorRef.current;
+ if (!el) return;
+ const id = `img-${nextImgId.current++}`;
+ inlineMap.current.set(id, file);
+ const img = document.createElement('img');
+ img.src = URL.createObjectURL(file);
+ img.setAttribute('data-inline-id', id);
+ img.style.maxWidth = '100%';
+ img.style.height = 'auto';
+
+ const sel = window.getSelection();
+ const caret = at ?? (sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.getRangeAt(0) : null);
+ if (caret && el.contains(caret.startContainer)) {
+ caret.deleteContents();
+ caret.insertNode(img);
+ caret.setStartAfter(img);
+ caret.collapse(true);
+ sel?.removeAllRanges();
+ sel?.addRange(caret);
+ } else {
+ el.appendChild(img);
+ }
+ refreshEmpty();
};
- const onPaste = (ev: React.ClipboardEvent) => {
- const pasted = Array.from(ev.clipboardData?.items ?? [])
- .filter((it) => it.kind === 'file')
- .map((it) => it.getAsFile())
- .filter((f): f is File => !!f);
- if (pasted.length) routeFiles(pasted);
+
+ // Paste inside the body: images insert at the caret, other files attach, text pastes as plain text.
+ const onEditorPaste = (ev: React.ClipboardEvent) => {
+ const fileItems = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file');
+ if (fileItems.length) {
+ ev.preventDefault();
+ fileItems
+ .map((it) => it.getAsFile())
+ .filter((f): f is File => !!f)
+ .map(named)
+ .forEach((f) => (f.type.startsWith('image/') ? insertImage(f) : addAttachments([f])));
+ return;
+ }
+ const text = ev.clipboardData?.getData('text/plain');
+ if (text) {
+ ev.preventDefault();
+ document.execCommand('insertText', false, text);
+ }
};
+
const onDrop = (ev: React.DragEvent) => {
ev.preventDefault();
setDragging(false);
- if (ev.dataTransfer?.files?.length) routeFiles(ev.dataTransfer.files);
+ const dropped = Array.from(ev.dataTransfer?.files ?? []).map(named);
+ if (!dropped.length) return;
+ // Place images at the drop point if it lands inside the body, otherwise append at the end.
+ const el = editorRef.current;
+ const point = document.caretRangeFromPoint?.(ev.clientX, ev.clientY) ?? null;
+ const range = point && el?.contains(point.startContainer) ? point : null;
+ dropped.forEach((f) => (f.type.startsWith('image/') ? insertImage(f, range) : addAttachments([f])));
};
- const close = () => setDraft(null);
+ const close = () => {
+ revokeInline();
+ setDraft(null);
+ };
const send = async () => {
if (!to.trim()) {
@@ -172,16 +212,30 @@ export const ComposeModal = () => {
if (cc.trim()) fd.append('cc', cc);
if (bcc.trim()) fd.append('bcc', bcc);
fd.append('subject', subject);
- fd.append('body', body);
if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo);
files.forEach((f) => fd.append('files', f));
- // Inline images: the backend gives each a cid `inline-` in this order; the html references them.
- if (inlineImages.length) {
- const esc = body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
');
- const imgs = inlineImages.map((_, i) => ``).join('');
- fd.append('html', `${esc}${esc ? '
' : ''}${imgs}`);
- inlineImages.forEach((f) => fd.append('inline', f));
+
+ // Serialize the body. Clone it, rewrite each inline
to a `cid:inline-N` ref (backend
+ // matches by order), and collect those files. Plain text goes as the fallback part.
+ const el = editorRef.current;
+ const inlineFiles: File[] = [];
+ if (el) {
+ const clone = el.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll('img[data-inline-id]').forEach((node) => {
+ const img = node as HTMLImageElement;
+ const file = inlineMap.current.get(img.getAttribute('data-inline-id') ?? '');
+ if (!file) return img.remove();
+ img.setAttribute('src', `cid:inline-${inlineFiles.length}`);
+ img.removeAttribute('data-inline-id');
+ inlineFiles.push(file);
+ });
+ fd.append('body', el.innerText);
+ if (inlineFiles.length) {
+ fd.append('html', clone.innerHTML);
+ inlineFiles.forEach((f) => fd.append('inline', f));
+ }
}
+
await client.post('/email/send', fd);
toast.success('Email sent');
// Gmail files the sent copy in Sent, so refresh the list to surface it.
@@ -202,7 +256,6 @@ export const ComposeModal = () => {