91 lines
3.5 KiB
TypeScript
91 lines
3.5 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
|
|
||
|
|
import { useT } from "../../i18n";
|
||
|
|
import { api } from "../../lib/api";
|
||
|
|
import type { MessageEnvelope } from "../../lib/types";
|
||
|
|
import { Attachments } from "./Attachments";
|
||
|
|
|
||
|
|
// Sanitized message HTML in a sandboxed iframe (SPEC §9.4, I-8). We use
|
||
|
|
// sandbox="allow-same-origin" WITHOUT allow-scripts: scripts still never run
|
||
|
|
// (the content is nh3-sanitized and script-less), but same-origin lets us (a)
|
||
|
|
// measure the content to auto-size the frame — so the message shows in full and
|
||
|
|
// only the thread panel scrolls — and (b) send the session cookie so inline
|
||
|
|
// (cid:) images authenticate and load.
|
||
|
|
export function MessageBody({ message }: { message: MessageEnvelope }) {
|
||
|
|
const t = useT();
|
||
|
|
const queryClient = useQueryClient();
|
||
|
|
const [allowed, setAllowed] = useState(false);
|
||
|
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
||
|
|
const observerRef = useRef<ResizeObserver | null>(null);
|
||
|
|
const [height, setHeight] = useState(80);
|
||
|
|
|
||
|
|
const { data, isLoading, isError } = useQuery({
|
||
|
|
queryKey: ["message-html", message.id, allowed],
|
||
|
|
queryFn: async () => {
|
||
|
|
const resp = await fetch(`/api/messages/${message.id}/html`, { credentials: "include" });
|
||
|
|
if (!resp.ok) throw new Error("failed to load message");
|
||
|
|
return resp.text();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const resize = useCallback(() => {
|
||
|
|
const doc = frameRef.current?.contentDocument;
|
||
|
|
if (doc?.documentElement) setHeight(doc.documentElement.scrollHeight);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const onLoad = () => {
|
||
|
|
const doc = frameRef.current?.contentDocument;
|
||
|
|
if (!doc) return;
|
||
|
|
resize();
|
||
|
|
// 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 });
|
||
|
|
});
|
||
|
|
observerRef.current?.disconnect();
|
||
|
|
const ro = new ResizeObserver(resize);
|
||
|
|
if (doc.body) ro.observe(doc.body);
|
||
|
|
observerRef.current = ro;
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => () => observerRef.current?.disconnect(), []);
|
||
|
|
|
||
|
|
const loadImages = async (persist: boolean) => {
|
||
|
|
if (persist) {
|
||
|
|
await api.allowRemoteImages(message.id);
|
||
|
|
queryClient.invalidateQueries({ queryKey: ["message-html", message.id] });
|
||
|
|
}
|
||
|
|
setAllowed(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
{message.has_remote_images && !allowed && (
|
||
|
|
<div className="flex flex-wrap items-center gap-3 border-b border-app-border bg-amber-100 px-3 py-1.5 text-xs text-amber-900 dark:bg-amber-900/30 dark:text-amber-200">
|
||
|
|
<span>{t("mail.imagesBlocked")}</span>
|
||
|
|
<button className="font-medium text-app-accent hover:underline" onClick={() => loadImages(false)}>
|
||
|
|
{t("mail.loadImages")}
|
||
|
|
</button>
|
||
|
|
<button className="font-medium text-app-accent hover:underline" onClick={() => loadImages(true)}>
|
||
|
|
{t("mail.alwaysFromSender")}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{isLoading && <div className="p-3 text-sm text-app-faint">{t("mail.loadingThread")}</div>}
|
||
|
|
{isError && <div className="p-3 text-sm text-red-500">{t("mail.loadFailed")}</div>}
|
||
|
|
{data !== undefined && !isLoading && (
|
||
|
|
<iframe
|
||
|
|
ref={frameRef}
|
||
|
|
title={`message-${message.id}`}
|
||
|
|
sandbox="allow-same-origin"
|
||
|
|
srcDoc={data}
|
||
|
|
onLoad={onLoad}
|
||
|
|
style={{ height }}
|
||
|
|
className="w-full border-0 bg-white"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{message.has_attachments && <Attachments messageId={message.id} />}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|