Skip to content
Source
Sign inSign up

PreviewStreamWebView.tsx

527 lines19.1 KB

1import { Button } from "../../components/ui/Button";
2import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
3import * as Dialog from "../../components/ui/Dialog";
4import previewStreamScript from "@t3tools/mobile-preview-stream";
5import {
6 previewStreamControlLabel,
7 previewStreamHostSetupMessage,
8 type PreviewStreamControl,
9 type PreviewStreamDownload,
10 type PreviewStreamFileChooser,
11 type PreviewStreamInput,
12} from "@t3tools/client-runtime/preview/server-browser-stream";
13import type { EnvironmentId, PreviewStreamHostSetup } from "@t3tools/contracts";
14import {
15 useEffectEvent,
16 useImperativeHandle,
17 useLayoutEffect,
18 useRef,
19 useState,
20 type Ref,
21} from "react";
22import { Platform, Pressable, TextInput, View } from "react-native";
23import { WebView } from "react-native-webview";
24import * as Clipboard from "expo-clipboard";
25​
26import { AppText } from "../../components/ui/AppText";
27import { PdfPreview } from "../../components/PdfPreview";
28import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
29import { beginForegroundHandoff } from "../../lib/foreground-handoff";
30import { usePreviewStreamAccess } from "../../state/preview";
31import { previewPromptText } from "./previewPromptText";
32​
33import {
34 previewStreamDocument,
35 previewStreamMessage,
36 type PreviewStreamConfiguration,
37} from "./preview-stream-document";
38​
39export interface PreviewStreamRef {
40 readonly command: (input: PreviewStreamInput) => void;
41 readonly paste: () => void;
42 readonly togglePictureInPicture: () => void;
44​
45export interface PreviewPictureInPictureState {
46 readonly supported: boolean;
47 readonly active: boolean;
49​
50type NativeStreamBridge = {
51 readonly ref?: Ref<PreviewStreamRef>;
52 readonly onUnauthorized: () => void;
53 /** Asks for fresh access after a refusal, once `delayMs` has passed. */
54 readonly onRefused: (delayMs: number) => void;
55 readonly onGone?: () => void;
56 readonly onViewport?: (viewport: { readonly width: number; readonly height: number }) => void;
57 readonly onControl?: (control: PreviewStreamControl | null) => void;
58 readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void;
59 /** True while frames show, so commands reach the page. */
60 readonly onStreamingChange?: (streaming: boolean) => void;
61 readonly compact?: boolean;
62 readonly pdfUri?: string | null;
63};
64​
65async function sendFilesToPage(chooser: PreviewStreamFileChooser, pick: boolean) {
66 const body = new FormData();
67 if (pick) {
68 const { getDocumentAsync } = await import("expo-document-picker");
69 const endHandoff = beginForegroundHandoff();
70 let result: Awaited<ReturnType<typeof getDocumentAsync>>;
71 try {
72 result = await getDocumentAsync({ multiple: chooser.multiple, copyToCacheDirectory: true });
73 } finally {
74 endHandoff();
75 }
76 for (const asset of result.canceled ? [] : result.assets) {
77 body.append("file", {
78 uri: asset.uri,
79 name: asset.name,
80 type: asset.mimeType ?? "application/octet-stream",
81 } as unknown as Blob);
82 }
83 }
84 const response = await fetch(chooser.uploadUrl, {
85 method: "POST",
86 body,
87 credentials: chooser.credentials ? "include" : "omit",
88 });
89 if (!response.ok) throw new Error((await response.text()) || "The upload was refused.");
91​
92function offerDownload(download: PreviewStreamDownload) {
93 Dialog.alert(`Downloaded ${download.fileName}`, undefined, [
94 { text: "Not now", style: "cancel" },
95 {
96 text: "Save or share",
97 onPress: () =>
98 void downloadAndShareAttachment({
99 url: download.url,
100 attachment: { name: download.fileName, mimeType: "application/octet-stream" },
101 signal: new AbortController().signal,
102 }).catch((cause: unknown) =>
103 Dialog.alert(
104 "Could not save the download",
105 cause instanceof Error ? cause.message : undefined,
106 ),
107 ),
108 },
109 ]);
111​
112const MAX_REFUSALS = 3;
113​
114export function PreviewStreamWebView(
115 props: Omit<PreviewStreamConfiguration, "access"> &
116 Omit<NativeStreamBridge, "onUnauthorized" | "onRefused"> & {
117 readonly environmentId: EnvironmentId;
118 readonly paused?: boolean;
119 },
120) {
121 const { access, error, refresh, retryAfter } = usePreviewStreamAccess(props.environmentId);
122 if (access && !props.paused) {
123 return (
124 <AuthorizedPreviewStream
125 {...props}
126 access={access}
127 onUnauthorized={refresh}
128 onRefused={retryAfter}
129 />
130 );
131 }
132 return (
133 <View
134 className={
135 props.compact
136 ? "flex-1 items-center justify-center"
137 : "flex-1 items-center justify-center gap-4 px-6"
138 }
139 >
140 {props.compact || !error ? <AppActivityIndicator colorClassName="accent-icon" /> : null}
141 {!props.compact && (
142 <>
143 <AppText
144 selectable={!!error}
145 className={
146 error ? "text-center text-sm text-foreground-muted" : "text-sm text-foreground-muted"
147 }
148 >
149 {error || "Connecting to browser..."}
150 </AppText>
151 {error && (
152 <View className="items-center">
153 <Button label="Retry" tone="secondary" onPress={refresh} />
154 </View>
155 )}
156 </>
157 )}
158 </View>
159 );
161​
162function AuthorizedPreviewStream({
163 ref,
164 ...props
165}: PreviewStreamConfiguration & NativeStreamBridge) {
166 const [attempt, setAttempt] = useState(0);
167 const [previousAccess, setPreviousAccess] = useState(props.access);
168 // Wait for refreshed access, including cookie credentials with unchanged JSON.
169 if (props.access !== previousAccess) {
170 setPreviousAccess(props.access);
171 setAttempt((current) => current + 1);
172 }
173 const processRetried = useRef(false);
174 const unauthorized = useRef(0);
175 const configuration = JSON.stringify({
176 access: props.access,
177 threadId: props.threadId,
178 tabId: props.tabId,
179 interactive: props.interactive,
180 background: props.background,
181 } satisfies PreviewStreamConfiguration);
182 return (
183 <PreviewStreamDocumentView
184 key={`${attempt}:${configuration}`}
185 {...props}
186 ref={ref}
187 configuration={configuration}
188 onUnauthorized={() => {
189 const refusals = ++unauthorized.current;
190 if (refusals >= MAX_REFUSALS) return false;
191 props.onRefused(refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1));
192 return true;
193 }}
194 onRetry={() => {
195 processRetried.current = false;
196 unauthorized.current = 0;
197 props.onUnauthorized();
198 }}
199 onStreaming={() => {
200 processRetried.current = false;
201 unauthorized.current = 0;
202 }}
203 onRecoverProcess={() => {
204 if (processRetried.current) return false;
205 processRetried.current = true;
206 setAttempt((current) => current + 1);
207 return true;
208 }}
209 />
210 );
212​
213function PreviewStreamDocumentView({
214 ref,
215 configuration,
216 background,
217 compact,
218 pdfUri,
219 onUnauthorized,
220 onGone,
221 onViewport,
222 onControl,
223 onPictureInPicture,
224 onStreamingChange,
225 onRetry,
226 onStreaming,
227 onRecoverProcess,
228}: Omit<NativeStreamBridge, "onUnauthorized" | "onRefused"> & {
229 readonly configuration: string;
230 readonly background: string;
231 readonly onUnauthorized: () => boolean;
232 readonly onRetry: () => void;
233 readonly onStreaming: () => void;
234 readonly onRecoverProcess: () => boolean;
235}) {
236 const webView = useRef<WebView<object>>(null);
237 const active = useRef(false);
238 const failed = useRef(false);
239 const bootstrapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
240 const [status, setStatus] = useState<"connecting" | "streaming" | "error">("connecting");
241 const [error, setError] = useState<string | null>(null);
242 const [gone, setGone] = useState(false);
243 const [hostSetup, setHostSetup] = useState<PreviewStreamHostSetup | null>(null);
244 const [control, setControl] = useState<PreviewStreamControl | null>(null);
245 const dialogRef = useRef<PreviewStreamControl["dialog"]>(null);
246 const [promptText, setPromptText] = useState("");
247 const [fileChooser, setFileChooser] = useState<PreviewStreamFileChooser | null>(null);
248 const answerFileChooser = (pick: boolean) => {
249 const chooser = fileChooser;
250 if (!chooser) return;
251 setFileChooser(null);
252 void sendFilesToPage(chooser, pick).catch((cause: unknown) =>
253 Dialog.alert(
254 "Could not send the files to the page",
255 cause instanceof Error ? cause.message : undefined,
256 ),
257 );
258 };
259 const command = (input: PreviewStreamInput) =>
260 webView.current?.injectJavaScript(
261 `window.T3PreviewStream?.command(${JSON.stringify(input)}); true;`,
262 );
263 // Reads here rather than in the screen: a tab switch during the iOS paste prompt unmounts
264 // this view and nulls its WebView ref, so the text can never type into another tab.
265 const paste = () =>
266 void Clipboard.getStringAsync().then(
267 (text) => {
268 if (text) command({ type: "text", text });
269 },
270 () => Dialog.alert("Could not read the clipboard"),
271 );
272 const clearBootstrapTimer = () => {
273 if (bootstrapTimer.current === null) return;
274 clearTimeout(bootstrapTimer.current);
275 bootstrapTimer.current = null;
276 };
277 const fail = (message: string) => {
278 if (!active.current || failed.current) return;
279 failed.current = true;
280 clearBootstrapTimer();
281 webView.current?.injectJavaScript("window.T3PreviewStream?.stop(); true;");
282 onStreamingChange?.(false);
283 setControl(null);
284 setFileChooser(null);
285 onControl?.(null);
286 setError(message);
287 setStatus("error");
288 };
289 const bootstrapTimedOut = useEffectEvent(() =>
290 fail("Browser viewer could not start. Reconnect to try again."),
291 );
292 const streamEnded = useEffectEvent(() => {
293 onStreamingChange?.(false);
294 onControl?.(null);
295 });
296 // The stream lives exactly as long as this view.
297 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the bootstrap timeout and stops the WebView stream when this view unmounts
298 useLayoutEffect(() => {
299 active.current = true;
300 const view = webView.current;
301 bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000);
302 return () => {
303 if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current);
304 bootstrapTimer.current = null;
305 active.current = false;
306 view?.injectJavaScript("window.T3PreviewStream?.stop(); true;");
307 streamEnded();
308 };
309 }, []);
310 const source = {
311 html: previewStreamDocument(configuration, previewStreamScript),
312 baseUrl: Platform.OS === "android" ? "https://localhost/" : "file:///",
313 };
314 useImperativeHandle(ref, () => ({
315 command,
316 paste,
317 togglePictureInPicture: () =>
318 webView.current?.injectJavaScript("window.T3PreviewStream?.pictureInPicture(); true;"),
319 }));
320 const processTerminated = () => {
321 if (!active.current || failed.current) return;
322 if (!onRecoverProcess()) fail("Browser viewer stopped. Reconnect to try again.");
323 };
324 return (
325 <View className="flex-1" style={{ backgroundColor: background }}>
326 {!compact ? (
327 <View className="flex-row items-center justify-between gap-2 border-b border-secondary-border px-3 py-2">
328 <AppText className="text-xs text-foreground-muted">
329 {previewStreamControlLabel(control)}
330 </AppText>
331 {control?.canOperate ? (
332 <Button
333 label={control.controller === "you" ? "Release control" : "Take control"}
334 tone="secondary"
335 disabled={control.controller === "another-viewer"}
336 onPress={() =>
337 command({ type: control.controller === "you" ? "releaseControl" : "takeControl" })
338 }
339 />
340 ) : null}
341 </View>
342 ) : null}
343 <View className="relative flex-1">
344 <WebView<object>
345 ref={webView}
346 source={source}
347 originWhitelist={["*"]}
348 scrollEnabled={false}
349 bounces={false}
350 mixedContentMode="always"
351 allowUniversalAccessFromFileURLs
352 contentInsetAdjustmentBehavior="never"
353 setSupportMultipleWindows={false}
354 allowsInlineMediaPlayback
355 allowsPictureInPictureMediaPlayback
356 mediaPlaybackRequiresUserAction={false}
357 style={{ flex: 1, backgroundColor: background }}
358 onError={() => fail("Browser viewer could not load. Reconnect to try again.")}
359 onHttpError={() => fail("Browser viewer could not load. Reconnect to try again.")}
360 onContentProcessDidTerminate={processTerminated}
361 onRenderProcessGone={processTerminated}
362 onShouldStartLoadWithRequest={(request) =>
363 request.url === "about:blank" || request.url === source.baseUrl
364 }
365 onMessage={(event) => {
366 if (!active.current || failed.current) return;
367 const message = previewStreamMessage(event.nativeEvent.data);
368 if (message === null) return;
369 switch (message.type) {
370 case "control": {
371 setControl(message);
372 const previousDialog = dialogRef.current;
373 dialogRef.current = message.dialog;
374 setPromptText((draft) => previewPromptText(previousDialog, message.dialog, draft));
375 onControl?.(message);
376 return;
377 }
378 case "unauthorized":
379 if (!onUnauthorized()) {
380 fail("This session can't open the browser stream. Reconnect to try again.");
381 }
382 return;
383 case "gone":
384 setGone(true);
385 fail("This tab was closed.");
386 onGone?.();
387 return;
388 case "hostSetup":
389 setHostSetup({ need: message.need, command: message.command });
390 fail(previewStreamHostSetupMessage(message));
391 return;
392 case "viewport":
393 onViewport?.(message);
394 return;
395 case "clipboard":
396 void Clipboard.setStringAsync(message.text).catch(() => undefined);
397 return;
398 case "download":
399 offerDownload(message);
400 return;
401 case "fileChooser":
402 setFileChooser(message.chooser);
403 return;
404 case "pictureInPicture":
405 onPictureInPicture?.(message, message.detail);
406 return;
407 case "status":
408 if (message.status !== "connecting") clearBootstrapTimer();
409 if (message.status === "error") {
410 fail(message.detail ?? "Browser stream failed.");
411 return;
412 }
413 setStatus(message.status);
414 if (message.status === "connecting") {
415 // Dialogs may have closed and reopened while this viewer was disconnected.
416 dialogRef.current = null;
417 setControl(null);
418 onControl?.(null);
419 }
420 onStreamingChange?.(message.status === "streaming");
421 if (message.status === "streaming") onStreaming();
422 }
423 }}
424 />
425 {pdfUri ? (
426 <View className="absolute inset-0">
427 <PdfPreview key={pdfUri} uri={pdfUri} />
428 </View>
429 ) : null}
430 </View>
431 {!compact && fileChooser && control?.controller === "you" ? (
432 <View className="absolute inset-x-3 top-16 gap-3 rounded-xl border border-secondary-border bg-secondary p-4">
433 <AppText className="text-sm text-secondary-foreground">
434 The page asks for {fileChooser.multiple ? "files" : "a file"}.
435 </AppText>
436 <View className="flex-row justify-end gap-3">
437 <Button label="Cancel" tone="text" onPress={() => answerFileChooser(false)} />
438 <Button
439 label={`Choose ${fileChooser.multiple ? "files" : "file"}`}
440 tone="secondary"
441 onPress={() => answerFileChooser(true)}
442 />
443 </View>
444 </View>
445 ) : null}
446 {!compact && control?.dialog ? (
447 <View className="absolute inset-x-3 top-16 gap-3 rounded-xl border border-secondary-border bg-secondary p-4">
448 <AppText className="text-sm text-secondary-foreground">{control.dialog.message}</AppText>
449 {control.controller === "you" ? (
450 <>
451 {control.dialog.type === "prompt" ? (
452 <TextInput
453 accessibilityLabel="Dialog response"
454 className="rounded-lg border border-input-border bg-input px-3 py-2 text-foreground"
455 placeholderTextColorClassName="accent-placeholder"
456 selectionColorClassName="accent-focus/32"
457 value={promptText}
458 onChangeText={setPromptText}
459 />
460 ) : null}
461 <View className="flex-row justify-end gap-3">
462 <Button
463 label="Dismiss"
464 tone="text"
465 onPress={() => command({ type: "dialog", accept: false })}
466 />
467 <Button
468 label="Accept"
469 tone="secondary"
470 onPress={() =>
471 command({
472 type: "dialog",
473 accept: true,
474 ...(control.dialog?.type === "prompt" ? { promptText } : {}),
475 })
476 }
477 />
478 </View>
479 </>
480 ) : (
481 <AppText className="text-xs text-foreground-muted">Take control to respond.</AppText>
482 )}
483 </View>
484 ) : null}
485 {status !== "streaming" ? (
486 <View
487 className="absolute inset-0 items-center justify-center gap-4 px-6"
488 style={{ backgroundColor: background }}
489 >
490 {status === "connecting" ? <AppActivityIndicator colorClassName="accent-icon" /> : null}
491 {compact ? null : (
492 <AppText
493 accessibilityLiveRegion="polite"
494 className="text-center text-sm text-foreground-muted"
495 >
496 {status === "error" ? error : "Connecting to browser..."}
497 </AppText>
498 )}
499 {status === "error" && hostSetup && !compact ? (
500 <Pressable
501 accessibilityRole="button"
502 accessibilityLabel={`Copy ${hostSetup.command}`}
503 className="w-full flex-row items-center gap-2 rounded-xl border border-secondary-border bg-secondary px-4 py-3"
504 onPress={() => {
505 void Clipboard.setStringAsync(hostSetup.command).catch(() => undefined);
506 }}
507 >
508 <AppText selectable className="flex-1 font-mono text-sm text-secondary-foreground">
509 {hostSetup.command}
510 </AppText>
511 <AppText className="text-xs text-foreground-muted">Copy</AppText>
512 </Pressable>
513 ) : null}
514 {status === "error" && !gone && !compact ? (
515 <View className="items-center">
516 <Button
517 label={hostSetup ? "Try again" : "Reconnect"}
518 tone="secondary"
519 onPress={onRetry}
520 />
521 </View>
522 ) : null}
523 </View>
524 ) : null}
525 </View>
526 );