Messages size at once; quoted inline images travel inside the reply

The message frame is set up as soon as its document is parsed instead of at
its load event, which waits for every image (remote ones included): it no
longer sits at its first height until the slowest image arrives.

A reply quoted an original's inline (cid:) images as this server's own URLs,
and they were sent like that — broken for the recipient, and mangled copies
("http:///api/…") made browsers look up a host named "api". Outgoing mail
(and the IMAP Drafts copy) now carries them as inline parts with new
Content-IDs; one that can no longer be had is left out. Shown messages load
such URLs from this server (sanitizer v5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:20:21 +02:00
co-authored by Claude Opus 5.5
parent d0caf76b03
commit 0514053252
6 changed files with 317 additions and 7 deletions
+36 -4
View File
@@ -118,9 +118,13 @@ export function MessageBody({
// else: relative / cid: / javascript: / data: / tel: — blocked, no navigation.
}, []);
const onLoad = () => {
const doc = frameRef.current?.contentDocument;
if (!doc) return;
// Set up the frame (sizing, link clicks, the quote toggle) as soon as its
// document is parsed — not at its load event, which waits for every image,
// remote ones included: until then the frame would stay at its first height.
const preparedRef = useRef<Document | null>(null);
const prepare = useCallback((doc: Document) => {
if (preparedRef.current === doc) return;
preparedRef.current = doc;
// The frame is auto-sized to its content, so it must never scroll itself — the
// thread panel is the scroll container. Hiding the document's own vertical
// overflow removes any phantom scrollbar left by sub-pixel/line-box rounding
@@ -134,14 +138,42 @@ export function MessageBody({
doc.addEventListener("click", handleFrameClick);
// Re-measure as images finish loading (they change the content height).
doc.querySelectorAll("img").forEach((img) => {
if (!img.complete) img.addEventListener("load", resize, { once: true });
if (!img.complete) {
img.addEventListener("load", resize, { once: true });
img.addEventListener("error", resize, { once: true });
}
});
observerRef.current?.disconnect();
const ro = new ResizeObserver(resize);
if (doc.body) ro.observe(doc.body);
observerRef.current = ro;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onLoad = () => {
const doc = frameRef.current?.contentDocument;
if (!doc) return;
prepare(doc);
resize(); // everything has loaded: one last measure
};
// Watch for the message's document to be parsed (the frame starts on an
// empty about:blank document, which does not count).
useEffect(() => {
if (data === undefined) return;
let frame = 0;
const check = () => {
const doc = frameRef.current?.contentDocument;
if (doc && doc.location.href === "about:srcdoc" && doc.readyState !== "loading" && doc.body) {
prepare(doc);
return;
}
frame = requestAnimationFrame(check);
};
frame = requestAnimationFrame(check);
return () => cancelAnimationFrame(frame);
}, [data, prepare]);
useEffect(() => () => observerRef.current?.disconnect(), []);
const loadImages = async (persist: boolean) => {