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