| 1 | import { useNavigation } from "@react-navigation/native"; |
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { |
| 4 | htmlRenderFileName, |
| 5 | htmlRenderFrameHeight, |
| 6 | htmlRenderThemeFragment, |
| 7 | htmlRenderThemeMessage, |
| 8 | type HtmlRenderReference, |
| 9 | type HtmlRenderTheme, |
| 10 | } from "@t3tools/shared/htmlRender"; |
| 11 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 12 | import { ActivityIndicator, Platform, Pressable, View, type ColorValue } from "react-native"; |
| 13 | import { WebView, type WebViewMessageEvent } from "react-native-webview"; |
| 14 | |
| 15 | import { SymbolView } from "../../components/AppSymbol"; |
| 16 | import { AppText as Text } from "../../components/AppText"; |
| 17 | import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; |
| 18 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 19 | import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; |
| 20 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 21 | |
| 22 | const ROW_BOTTOM_MARGIN = 8; |
| 23 | const FULL_SCREEN_GUTTER = 16; |
| 24 | |
| 25 | /** A render row is its frame's height plus spacing; the page's content never sizes it. */ |
| 26 | export function htmlRenderRowHeight(frameHeight: number) { |
| 27 | return frameHeight + ROW_BOTTOM_MARGIN; |
| 28 | } |
| 29 | |
| 30 | function 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 | |
| 46 | function postTheme(view: WebView<object> | null, theme: HtmlRenderTheme) { |
| 47 | view?.injectJavaScript( |
| 48 | `window.postMessage(${JSON.stringify(htmlRenderThemeMessage(theme))}, "*"); true;`, |
| 49 | ); |
| 50 | } |
| 51 | |
| 52 | const 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. |
| 56 | 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;`; |
| 57 | |
| 58 | type Overflow = { readonly x: boolean; readonly y: boolean }; |
| 59 | |
| 60 | const NO_OVERFLOW: Overflow = { x: false, y: false }; |
| 61 | |
| 62 | function 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 | |
| 80 | const 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 | */ |
| 86 | export 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. */ |
| 195 | export 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 | } |