1import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
2import { useNavigation } from "@react-navigation/native";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import {
5 htmlRenderFileName,
6 htmlRenderFrameHeight,
7 htmlRenderThemeFragment,
8 htmlRenderThemeMessage,
9 type HtmlRenderReference,
10 type HtmlRenderTheme,
11} from "@t3tools/shared/htmlRender";
12import { useEffect, useRef, useState } from "react";
13import { Platform, Pressable, View, type ColorValue } from "react-native";
14import { WebView, type WebViewMessageEvent } from "react-native-webview";
15
16import { SymbolView } from "../../components/ui/AppSymbol";
17import { AppText as Text } from "../../components/ui/AppText";
18import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme";
19import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
20import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
21import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
22import { webViewDecelerationRate } from "../../lib/webViewDecelerationRate";
23
24const ROW_BOTTOM_MARGIN = 8;
25const FULL_SCREEN_GUTTER = 16;
26
27export function htmlRenderRowHeight(frameHeight: number) {
28 return frameHeight + ROW_BOTTOM_MARGIN;
29}
30
31function useHtmlRenderTheme() {
32 const { themeId, themeAppearance, themeVariables, systemColorsActive } =
33 useAppearancePreferences();
34 return mobileHtmlRenderTheme({
35 themeId,
36 appearance: themeAppearance,
37 variables: themeVariables,
38 systemColors: systemColorsActive,
39 platform: Platform.OS,
40 });
41}
42
43function postTheme(view: WebView<object> | null, theme: HtmlRenderTheme) {
44 view?.injectJavaScript(
45 `window.postMessage(${JSON.stringify(htmlRenderThemeMessage(theme))}, "*"); true;`,
46 );
47}
48
49const OVERFLOW_MESSAGE_TYPE = "t3-html-render-overflow";
50
51const 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;`;
52
53type Overflow = { readonly x: boolean; readonly y: boolean };
54
55const NO_OVERFLOW: Overflow = { x: false, y: false };
56
57function readOverflowMessage(data: string): Overflow | null {
58 try {
59 const message: unknown = JSON.parse(data);
60 return typeof message === "object" &&
61 message !== null &&
62 "type" in message &&
63 message.type === OVERFLOW_MESSAGE_TYPE &&
64 "x" in message &&
65 typeof message.x === "boolean" &&
66 "y" in message &&
67 typeof message.y === "boolean"
68 ? { x: message.x, y: message.y }
69 : null;
70 } catch {
71 return null;
72 }
73}
74
75const withoutFragment = (url: string) => url.split("#", 1)[0];
76
77export function HtmlRenderWebView(props: {
78 readonly uri: string;
79 readonly title: string;
80 readonly nested: boolean;
81 readonly onLoadError?: () => void;
82}) {
83 const theme = useHtmlRenderTheme();
84 const [initialTheme] = useState(theme);
85 const [generation, setGeneration] = useState(0);
86 const [loaded, setLoaded] = useState(false);
87 const [overflow, setOverflow] = useState(NO_OVERFLOW);
88 const webView = useRef<WebView<object>>(null);
89 const crashes = useRef(0);
90 const shownTheme = useRef<HtmlRenderTheme | null>(null);
91 const source = { uri: props.uri + htmlRenderThemeFragment(initialTheme) };
92 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- posts theme changes into the already loaded WebView
93 useEffect(() => {
94 if (shownTheme.current === null || shownTheme.current === theme) return;
95 shownTheme.current = theme;
96 postTheme(webView.current, theme);
97 }, [theme]);
98 const restart = () => {
99 crashes.current += 1;
100 if (crashes.current > 1) {
101 props.onLoadError?.();
102 return;
103 }
104 shownTheme.current = null;
105 setLoaded(false);
106 setOverflow(NO_OVERFLOW);
107 setGeneration((value) => value + 1);
108 };
109 const scrollable = !props.nested || overflow.x || overflow.y;
110 return (
111 <View
112 style={
113 props.nested
114 ? { flex: 1 }
115 : {
116 flex: 1,
117 paddingHorizontal: FULL_SCREEN_GUTTER,
118 backgroundColor: theme.variables["--background"],
119 }
120 }
121 >
122 <WebView<object>
123 key={generation}
124 ref={webView}
125 source={source}
126 accessibilityLabel={props.title}
127 style={{ flex: 1, backgroundColor: "transparent" }}
128 allowsInlineMediaPlayback
129 contentInsetAdjustmentBehavior={props.nested ? "never" : "automatic"}
130 bounces={!props.nested}
131 showsVerticalScrollIndicator={!props.nested}
132 showsHorizontalScrollIndicator={!props.nested}
133 scrollEnabled={scrollable}
134 decelerationRate={webViewDecelerationRate}
135 // Android's patched WebView always keeps horizontal drags. Only pages
136 // with vertical overflow hold vertical drags until they reach an edge.
137 nestedScrollEnabled={props.nested && overflow.y}
138 overScrollMode={props.nested ? "never" : "always"}
139 onShouldStartLoadWithRequest={(request) =>
140 request.isTopFrame === false ||
141 withoutFragment(request.url) === withoutFragment(props.uri)
142 }
143 onOpenWindow={(event) => {
144 const url = event.nativeEvent.targetUrl;
145 if (/^https?:/i.test(url)) void tryOpenExternalUrl(url, "html-render");
146 }}
147 onLoadEnd={() => {
148 setLoaded(true);
149 shownTheme.current = theme;
150 if (theme !== initialTheme) postTheme(webView.current, theme);
151 }}
152 onError={props.onLoadError}
153 onHttpError={props.onLoadError}
154 onContentProcessDidTerminate={restart}
155 onRenderProcessGone={restart}
156 {...(props.nested
157 ? {
158 injectedJavaScript: OVERFLOW_SCRIPT,
159 onMessage: (event: WebViewMessageEvent) => {
160 const next = readOverflowMessage(event.nativeEvent.data);
161 if (next !== null) setOverflow(next);
162 },
163 }
164 : {})}
165 />
166 {loaded ? null : (
167 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
168 <AppActivityIndicator />
169 </View>
170 )}
171 </View>
172 );
173}
174
175export function ThreadHtmlRender(props: {
176 readonly environmentId: EnvironmentId;
177 readonly threadId: ThreadId;
178 readonly render: HtmlRenderReference;
179 readonly frameWidth: number;
180 readonly iconColor: ColorValue;
181}) {
182 const navigation = useNavigation();
183 const { attachmentId, title } = props.render;
184 const height = htmlRenderFrameHeight(props.render, props.frameWidth);
185 const fileName = htmlRenderFileName(title);
186 const resource = {
187 _tag: "attachment" as const,
188 attachmentId,
189 fileName,
190 mimeType: "text/html",
191 disposition: "inline" as const,
192 };
193 const asset = useAssetUrlState(props.environmentId, resource);
194 const refresh = useRefreshAssetUrl(props.environmentId, resource);
195 // Signed URLs are re-minted periodically; following them would reload the page.
196 const [uri, setUri] = useState<string | null>(null);
197 if (uri === null && asset._tag === "Success") setUri(asset.url);
198 const [failed, setFailed] = useState(false);
199 const [attempt, setAttempt] = useState(0);
200 const retried = useRef(false);
201 const handleLoadError = () => {
202 if (retried.current) {
203 setFailed(true);
204 return;
205 }
206 retried.current = true;
207 void refresh().then((next) => {
208 if (next === null) setFailed(true);
209 else if (next !== uri) setUri(next);
210 else setAttempt((value) => value + 1);
211 });
212 };
213
214 return (
215 <View style={{ marginBottom: ROW_BOTTOM_MARGIN }}>
216 <View style={{ height }}>
217 {uri !== null && !failed ? (
218 <HtmlRenderWebView
219 key={`${uri}:${attempt}`}
220 uri={uri}
221 title={title}
222 nested
223 onLoadError={handleLoadError}
224 />
225 ) : failed || asset._tag === "Failure" ? (
226 <Pressable
227 accessibilityRole="button"
228 accessibilityLabel={`Reload ${title}`}
229 className="flex-1 items-center justify-center"
230 onPress={() => {
231 retried.current = false;
232 setFailed(false);
233 if (uri === null) void refresh().then((next) => next !== null && setUri(next));
234 }}
235 >
236 <Text className="text-sm text-foreground-muted">Page unavailable</Text>
237 </Pressable>
238 ) : (
239 <View className="flex-1 items-center justify-center">
240 <AppActivityIndicator />
241 </View>
242 )}
243 {uri !== null && !failed ? (
244 <Pressable
245 accessibilityRole="button"
246 accessibilityLabel={`Open ${title}`}
247 hitSlop={8}
248 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"
249 onPress={() =>
250 navigation.navigate("ThreadAttachment", {
251 environmentId: String(props.environmentId),
252 threadId: String(props.threadId),
253 attachmentId,
254 name: fileName,
255 mimeType: "text/html",
256 sizeBytes: "0",
257 htmlRender: "1",
258 })
259 }
260 >
261 <SymbolView
262 name="arrow.up.left.and.arrow.down.right"
263 size={12}
264 tintColor={props.iconColor}
265 type="monochrome"
266 />
267 </Pressable>
268 ) : null}
269 </View>
270 </View>
271 );
272}