1import { useNavigation } from "@react-navigation/native";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import {
4 htmlRenderFileName,
5 htmlRenderFrameHeight,
6 htmlRenderThemeFragment,
7 htmlRenderThemeMessage,
8 type HtmlRenderReference,
9 type HtmlRenderTheme,
10} from "@t3tools/shared/htmlRender";
11import { useEffect, useMemo, useRef, useState } from "react";
12import { ActivityIndicator, Platform, Pressable, View, type ColorValue } from "react-native";
13import { WebView, type WebViewMessageEvent } from "react-native-webview";
14
15import { SymbolView } from "../../components/AppSymbol";
16import { AppText as Text } from "../../components/AppText";
17import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme";
18import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
19import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
20import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
21
22const ROW_BOTTOM_MARGIN = 8;
23const FULL_SCREEN_GUTTER = 16;
24
25/** A render row is its frame's height plus spacing; the page's content never sizes it. */
26export function htmlRenderRowHeight(frameHeight: number) {
27 return frameHeight + ROW_BOTTOM_MARGIN;
28}
29
30function useHtmlRenderTheme() {
31 const { themeId, themeAppearance, themeVariables, systemColorsActive } =
32 useAppearancePreferences();
33 return useMemo(
34 () =>
35 mobileHtmlRenderTheme({
36 themeId,
37 appearance: themeAppearance,
38 variables: themeVariables,
39 systemColors: systemColorsActive,
40 platform: Platform.OS,
41 }),
42 [themeId, themeAppearance, themeVariables, systemColorsActive],
43 );
44}
45
46function postTheme(view: WebView<object> | null, theme: HtmlRenderTheme) {
47 view?.injectJavaScript(
48 `window.postMessage(${JSON.stringify(htmlRenderThemeMessage(theme))}, "*"); true;`,
49 );
50}
51
52const OVERFLOW_MESSAGE_TYPE = "t3-html-render-overflow";
53
54// Reports which ways the page overflows its frame, so a feed row only takes
55// scroll gestures from a page that can use them.
56const OVERFLOW_SCRIPT = `(function(){var last;function report(){var d=document.documentElement,b=document.body;var x=Math.max(d.scrollWidth,b?b.scrollWidth:0)>window.innerWidth+1,y=Math.max(d.scrollHeight,b?b.scrollHeight:0)>window.innerHeight+1,o=x+","+y;if(o===last)return;last=o;window.ReactNativeWebView.postMessage(JSON.stringify({type:${JSON.stringify(OVERFLOW_MESSAGE_TYPE)},x:x,y:y}));}report();if(window.ResizeObserver){var r=new ResizeObserver(report);r.observe(document.documentElement);if(document.body)r.observe(document.body);}window.addEventListener("resize",report);})();true;`;
57
58type Overflow = { readonly x: boolean; readonly y: boolean };
59
60const NO_OVERFLOW: Overflow = { x: false, y: false };
61
62function readOverflowMessage(data: string): Overflow | null {
63 try {
64 const message: unknown = JSON.parse(data);
65 return typeof message === "object" &&
66 message !== null &&
67 "type" in message &&
68 message.type === OVERFLOW_MESSAGE_TYPE &&
69 "x" in message &&
70 typeof message.x === "boolean" &&
71 "y" in message &&
72 typeof message.y === "boolean"
73 ? { x: message.x, y: message.y }
74 : null;
75 } catch {
76 return null;
77 }
78}
79
80const withoutFragment = (url: string) => url.split("#", 1)[0];
81
82/**
83 * An agent's HTML page, themed before first paint and kept in step with the app theme.
84 * The page may move within its own document; links leave for the browser.
85 */
86export function HtmlRenderWebView(props: {
87 readonly uri: string;
88 readonly title: string;
89 /** Inside the feed, the page takes scroll gestures only when it overflows its frame. */
90 readonly nested: boolean;
91 readonly onLoadError?: () => void;
92}) {
93 const theme = useHtmlRenderTheme();
94 const [initialTheme] = useState(theme);
95 const [generation, setGeneration] = useState(0);
96 const [loaded, setLoaded] = useState(false);
97 const [overflow, setOverflow] = useState(NO_OVERFLOW);
98 const webView = useRef<WebView<object>>(null);
99 const crashes = useRef(0);
100 // The theme the loaded document shows; null until it loads.
101 const shownTheme = useRef<HtmlRenderTheme | null>(null);
102 const source = useMemo(
103 () => ({ uri: props.uri + htmlRenderThemeFragment(initialTheme) }),
104 [props.uri, initialTheme],
105 );
106 useEffect(() => {
107 if (shownTheme.current === null || shownTheme.current === theme) return;
108 shownTheme.current = theme;
109 postTheme(webView.current, theme);
110 }, [theme]);
111 const restart = () => {
112 // A page that keeps crashing its web process is not reloaded forever.
113 crashes.current += 1;
114 if (crashes.current > 1) {
115 props.onLoadError?.();
116 return;
117 }
118 shownTheme.current = null;
119 setLoaded(false);
120 setOverflow(NO_OVERFLOW);
121 setGeneration((value) => value + 1);
122 };
123 const scrollable = !props.nested || overflow.x || overflow.y;
124 // Pages have no horizontal padding of their own, so full screen adds the
125 // feed's gutter in the page's background color.
126 return (
127 <View
128 style={
129 props.nested
130 ? { flex: 1 }
131 : {
132 flex: 1,
133 paddingHorizontal: FULL_SCREEN_GUTTER,
134 backgroundColor: theme.variables["--background"],
135 }
136 }
137 >
138 <WebView<object>
139 key={generation}
140 ref={webView}
141 source={source}
142 accessibilityLabel={props.title}
143 style={{ flex: 1, backgroundColor: "transparent" }}
144 allowsInlineMediaPlayback
145 automaticallyAdjustContentInsets={!props.nested}
146 bounces={!props.nested}
147 showsVerticalScrollIndicator={!props.nested}
148 showsHorizontalScrollIndicator={!props.nested}
149 scrollEnabled={scrollable}
150 // Android only: the page holds vertical drags until it reaches an edge,
151 // then hands them to the feed (patches/react-native-webview). A page
152 // that only overflows sideways leaves vertical drags to the feed.
153 nestedScrollEnabled={props.nested && overflow.y}
154 overScrollMode={props.nested ? "never" : "always"}
155 // Only the page itself loads here; other top-frame navigations are
156 // dropped. A link the reader taps opens as a new window, which the
157 // platform allows only from a tap, and goes to the browser.
158 onShouldStartLoadWithRequest={(request) =>
159 request.isTopFrame === false ||
160 withoutFragment(request.url) === withoutFragment(props.uri)
161 }
162 onOpenWindow={(event) => {
163 const url = event.nativeEvent.targetUrl;
164 if (/^https?:/i.test(url)) void tryOpenExternalUrl(url, "html-render");
165 }}
166 onLoadEnd={() => {
167 setLoaded(true);
168 shownTheme.current = theme;
169 if (theme !== initialTheme) postTheme(webView.current, theme);
170 }}
171 onError={props.onLoadError}
172 onHttpError={props.onLoadError}
173 onContentProcessDidTerminate={restart}
174 onRenderProcessGone={restart}
175 {...(props.nested
176 ? {
177 injectedJavaScript: OVERFLOW_SCRIPT,
178 onMessage: (event: WebViewMessageEvent) => {
179 const next = readOverflowMessage(event.nativeEvent.data);
180 if (next !== null) setOverflow(next);
181 },
182 }
183 : {})}
184 />
185 {loaded ? null : (
186 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
187 <ActivityIndicator />
188 </View>
189 )}
190 </View>
191 );
192}
193
194/** A completed `html_render` call in the thread feed: the page itself, at a fixed height. */
195export function ThreadHtmlRender(props: {
196 readonly environmentId: EnvironmentId;
197 readonly threadId: ThreadId;
198 readonly render: HtmlRenderReference;
199 /** The feed's content width; the frame's height follows the page's measured height there. */
200 readonly frameWidth: number;
201 readonly iconColor: ColorValue;
202}) {
203 const navigation = useNavigation();
204 const { attachmentId, title } = props.render;
205 const height = htmlRenderFrameHeight(props.render, props.frameWidth);
206 const fileName = htmlRenderFileName(title);
207 const resource = useMemo(
208 () => ({
209 _tag: "attachment" as const,
210 attachmentId,
211 fileName,
212 mimeType: "text/html",
213 disposition: "inline" as const,
214 }),
215 [attachmentId, fileName],
216 );
217 const asset = useAssetUrlState(props.environmentId, resource);
218 const refresh = useRefreshAssetUrl(props.environmentId, resource);
219 // Signed URLs are re-minted periodically; following them would reload the page.
220 const [uri, setUri] = useState<string | null>(null);
221 if (uri === null && asset._tag === "Success") setUri(asset.url);
222 const [failed, setFailed] = useState(false);
223 // A failed load retries once with a fresh URL, or remounts on the same one,
224 // since the failure may have been the connection rather than the URL.
225 const [attempt, setAttempt] = useState(0);
226 const retried = useRef(false);
227 const handleLoadError = () => {
228 if (retried.current) {
229 setFailed(true);
230 return;
231 }
232 retried.current = true;
233 void refresh().then((next) => {
234 if (next === null) setFailed(true);
235 else if (next !== uri) setUri(next);
236 else setAttempt((value) => value + 1);
237 });
238 };
239
240 return (
241 <View style={{ marginBottom: ROW_BOTTOM_MARGIN }}>
242 <View style={{ height }}>
243 {uri !== null && !failed ? (
244 <HtmlRenderWebView
245 key={`${uri}:${attempt}`}
246 uri={uri}
247 title={title}
248 nested
249 onLoadError={handleLoadError}
250 />
251 ) : failed || asset._tag === "Failure" ? (
252 <Pressable
253 accessibilityRole="button"
254 accessibilityLabel={`Reload ${title}`}
255 className="flex-1 items-center justify-center"
256 onPress={() => {
257 retried.current = false;
258 setFailed(false);
259 if (uri === null) void refresh().then((next) => next !== null && setUri(next));
260 }}
261 >
262 <Text className="text-sm text-foreground-muted">Page unavailable</Text>
263 </Pressable>
264 ) : (
265 <View className="flex-1 items-center justify-center">
266 <ActivityIndicator />
267 </View>
268 )}
269 {uri !== null && !failed ? (
270 <Pressable
271 accessibilityRole="button"
272 accessibilityLabel={`Open ${title}`}
273 hitSlop={8}
274 className="absolute right-1.5 top-1.5 h-7 w-7 items-center justify-center rounded-full border border-border/60 bg-card/80"
275 onPress={() =>
276 navigation.navigate("ThreadAttachment", {
277 environmentId: String(props.environmentId),
278 threadId: String(props.threadId),
279 attachmentId,
280 name: fileName,
281 mimeType: "text/html",
282 sizeBytes: "0",
283 htmlRender: "1",
284 })
285 }
286 >
287 <SymbolView
288 name="arrow.up.left.and.arrow.down.right"
289 size={12}
290 tintColor={props.iconColor}
291 type="monochrome"
292 />
293 </Pressable>
294 ) : null}
295 </View>
296 </View>
297 );
298}