Skip to content
Source
Sign inSign up

GuestHtmlRender.tsx

96 lines3.5 KB

1import { GUEST_RENDER_ROUTE, GUEST_SHARE_PARAM } from "@t3tools/contracts";
2import {
3 type HtmlRenderReference,
4 HTML_RENDER_COLUMN_WIDTH,
5 htmlRenderFrameHeight,
6 htmlRenderResult,
7 htmlRenderThemeFragment,
8 htmlRenderThemeMessage,
9 readHtmlRenderContentHeight,
10 readHtmlRenderLinkRequest,
11} from "@t3tools/shared/htmlRender";
12import { useEffect, useLayoutEffect, useRef, useState } from "react";
13​
14import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
15import { observeResize } from "~/lib/observeResize";
16​
17/**
18 * A visual the agent published, as the guest sees it. The server sends it with a sandbox CSP and
19 * the frame is sandboxed too, so the page runs scripts in an opaque origin. It can size itself
20 * and ask to open a web link; it cannot reach the guest's session.
21 */
22export function GuestHtmlRender({
23 itemId,
24 reference,
25 shareId,
26}: {
27 readonly itemId: string;
28 readonly reference: HtmlRenderReference;
29 readonly shareId: string;
30}) {
31 const theme = useHtmlRenderTheme();
32 const boxRef = useRef<HTMLDivElement>(null);
33 const frameRef = useRef<HTMLIFrameElement>(null);
34 const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH);
35 const [contentHeight, setContentHeight] = useState<number>();
36 // The theme rides in the fragment for the first paint; later changes are posted.
37 const [src] = useState(
38 () =>
39 `${GUEST_RENDER_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&item=${encodeURIComponent(itemId)}${htmlRenderThemeFragment(theme)}`,
40 );
41 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the frame box and follows its width with a ResizeObserver before paint
42 useLayoutEffect(() => {
43 const box = boxRef.current;
44 if (!box) return;
45 setWidth(box.clientWidth);
46 return observeResize(box, ([entry]) => {
47 if (entry) setWidth(entry.contentRect.width);
48 });
49 }, []);
50 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- posts theme changes to the iframe's window
51 useEffect(() => {
52 frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*");
53 }, [theme]);
54 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window message listener for the iframe's height reports and link requests
55 useEffect(() => {
56 const onMessage = (event: MessageEvent) => {
57 const frame = frameRef.current;
58 if (frame === null || event.source !== frame.contentWindow) return;
59 const height = readHtmlRenderContentHeight(event.data);
60 if (height !== undefined) {
61 setContentHeight(height);
62 return;
63 }
64 // Only while the reader is using this frame, so a page cannot open links on its own.
65 const link = readHtmlRenderLinkRequest(event.data);
66 if (
67 link === undefined ||
68 document.activeElement !== frame ||
69 navigator.userActivation?.isActive === false
70 ) {
71 return;
72 }
73 window.open(link.url, "_blank", "noopener,noreferrer");
74 frame.contentWindow?.postMessage(htmlRenderResult(link.id), "*");
75 };
76 window.addEventListener("message", onMessage);
77 return () => window.removeEventListener("message", onMessage);
78 }, []);
79​
80 return (
81 <div
82 ref={boxRef}
83 style={{
84 height: htmlRenderFrameHeight(reference, width, contentHeight),
85 }}
86 >
87 <iframe
88 ref={frameRef}
89 src={src}
90 title={reference.title}
91 sandbox="allow-scripts allow-forms allow-popups allow-downloads"
92 className="block size-full rounded-lg border border-border"
93 />
94 </div>
95 );