Open History to see the last commit
1import { SheetHeader } from "../../components/ui/SheetHeader";
2import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
3import * as Dialog from "../../components/ui/Dialog";
4import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
5import { EnvironmentId, ThreadId, type PreviewSessionSnapshot } from "@t3tools/contracts";
6import { normalizePreviewUrl, previewPdfAssetUrl } from "@t3tools/shared/preview";
7import * as Option from "effect/Option";
8import { useEffect, useRef, useState } from "react";
9import { Platform, TextInput, View } from "react-native";
10import { useSafeAreaInsets } from "react-native-safe-area-context";
11
12import { ControlPill } from "../../components/ui/ControlPill";
13import { useContentTopInset } from "../../components/ui/useBarInset";
14import { useAppForeground } from "../../lib/appState";
15
16import { useThreadServerBrowserTabs } from "../../state/preview";
17import { usePreparedConnection } from "../../state/session";
18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
19import { BrowserTabMenu } from "./BrowserTabMenu";
20import { browserTabTitle, browserTabUrl, latestBrowserTab } from "./browserTabs";
21import {
22 PreviewStreamWebView,
23 type PreviewPictureInPictureState,
24 type PreviewStreamRef,
25} from "./PreviewStreamWebView";
26
27const NO_PICTURE_IN_PICTURE: PreviewPictureInPictureState = { supported: false, active: false };
28
29type BrowserPreviewRouteScreenProps = StaticScreenProps<{
30 readonly environmentId: string;
31 readonly threadId: string;
32 readonly tabId?: string;
33}>;
34
35export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) {
36 const navigation = useNavigation();
37 const onClose = () => navigation.goBack();
38 const { environmentId, threadId } = route.params;
39 if (!environmentId?.trim() || !threadId?.trim()) {
40 return <BrowserUnavailableScreen onClose={onClose} />;
41 }
42 return (
43 <BrowserPreviewScreen
44 environmentId={EnvironmentId.make(environmentId)}
45 threadId={ThreadId.make(threadId)}
46 initialTabId={route.params.tabId ?? null}
47 onClose={onClose}
48 />
49 );
50}
51
52function BrowserUnavailableScreen({ onClose }: { readonly onClose: () => void }) {
53 const insets = useSafeAreaInsets();
54 const contentTopInset = useContentTopInset();
55 return (
56 <View
57 className="flex-1 bg-sheet"
58 style={{ paddingTop: contentTopInset, paddingBottom: insets.bottom }}
59 >
60 <SheetHeader title="Browser" onClose={onClose} accessibilityLabel="Close browser" />
61 </View>
62 );
63}
64
65function BrowserPreviewScreen({
66 environmentId,
67 threadId,
68 initialTabId,
69 onClose,
70}: {
71 readonly environmentId: EnvironmentId;
72 readonly threadId: ThreadId;
73 readonly initialTabId: string | null;
74 readonly onClose: () => void;
75}) {
76 const insets = useSafeAreaInsets();
77 const contentTopInset = useContentTopInset();
78 const { themeVariables } = useAppearancePreferences();
79 const prepared = usePreparedConnection(environmentId);
80 const httpBaseUrl = Option.isSome(prepared) ? prepared.value.httpBaseUrl : null;
81 const focused = useIsFocused();
82 const foreground = useAppForeground();
83 const [selectedTabId, setSelectedTabId] = useState(initialTabId);
84 const [pictureInPicture, setPictureInPicture] = useState(NO_PICTURE_IN_PICTURE);
85 const [streaming, setStreaming] = useState(false);
86 const [canControl, setCanControl] = useState(false);
87 const streamRef = useRef<PreviewStreamRef>(null);
88 const { tabs, loaded } = useThreadServerBrowserTabs({ environmentId, threadId, enabled: true });
89 const tab = tabs.find((entry) => entry.tabId === selectedTabId) ?? latestBrowserTab(tabs);
90 const pdfUri =
91 tab?.navStatus._tag === "Success" ? previewPdfAssetUrl(tab.navStatus.url, httpBaseUrl) : null;
92 const tabId = tab?.tabId ?? null;
93 if (tabId !== null && tabId !== selectedTabId) {
94 setPictureInPicture(NO_PICTURE_IN_PICTURE);
95 setSelectedTabId(tabId);
96 }
97 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes the focused route through the navigator once the thread has no browser tabs left
98 useEffect(() => {
99 if (focused && loaded && tabs.length === 0) onClose();
100 }, [focused, loaded, tabs.length, onClose]);
101 const onPictureInPicture = (state: PreviewPictureInPictureState, detail?: string) => {
102 setPictureInPicture({ supported: state.supported, active: state.active });
103 if (detail) Dialog.alert("Picture in picture is unavailable", detail);
104 };
105 const selectTab = (next: string) => {
106 setPictureInPicture(NO_PICTURE_IN_PICTURE);
107 setSelectedTabId(next);
108 };
109 const live = (focused && foreground) || pictureInPicture.active;
110
111 return (
112 <View
113 className="flex-1 bg-sheet"
114 style={{ paddingTop: contentTopInset, paddingBottom: insets.bottom }}
115 >
116 <SheetHeader
117 title={tab ? browserTabTitle(tab) : "Browser"}
118 onClose={onClose}
119 accessibilityLabel="Close browser"
120 actions={
121 // Android WebView cannot play video in picture in picture.
122 Platform.OS === "ios" &&
123 pictureInPicture.supported &&
124 (pictureInPicture.active || pdfUri === null)
125 ? [
126 {
127 accessibilityLabel: pictureInPicture.active
128 ? "Exit picture in picture"
129 : "Picture in picture",
130 icon: {
131 ios: pictureInPicture.active ? "pip.exit" : "pip.enter",
132 android: "visibility",
133 },
134 selected: pictureInPicture.active,
135 onPress: () => streamRef.current?.togglePictureInPicture(),
136 },
137 ]
138 : []
139 }
140 menus={
141 tabs.length > 1
142 ? [
143 {
144 title: "Tabs",
145 icon: "square.on.square",
146 items: tabs.map((entry) => ({
147 id: entry.tabId,
148 title: browserTabTitle(entry),
149 subtitle: browserTabUrl(entry),
150 selected: entry.tabId === tabId,
151 onPress: () => selectTab(entry.tabId),
152 })),
153 },
154 ]
155 : []
156 }
157 />
158 {tab ? (
159 <>
160 <BrowserAddressBar
161 key={`address:${tab.tabId}`}
162 environmentId={environmentId}
163 tab={tab}
164 streaming={streaming}
165 ready={streaming && canControl}
166 onCommand={(input) => streamRef.current?.command(input)}
167 onPaste={() => streamRef.current?.paste()}
168 />
169 {live ? (
170 <PreviewStreamWebView
171 key={`stream:${tab.tabId}`}
172 environmentId={environmentId}
173 threadId={threadId}
174 tabId={tab.tabId}
175 pdfUri={pdfUri}
176 ref={streamRef}
177 interactive
178 background={themeVariables["--color-sheet-solid"]}
179 onPictureInPicture={onPictureInPicture}
180 onStreamingChange={setStreaming}
181 onControl={(control) => setCanControl(control?.controller === "you")}
182 />
183 ) : (
184 <View className="flex-1" />
185 )}
186 </>
187 ) : (
188 <View className="flex-1 items-center justify-center">
189 {focused && foreground ? (
190 <AppActivityIndicator color={themeVariables["--color-icon"]} />
191 ) : null}
192 </View>
193 )}
194 </View>
195 );
196}
197
198function BrowserAddressBar({
199 environmentId,
200 tab,
201 ready,
202 streaming,
203 onCommand,
204 onPaste,
205}: {
206 readonly environmentId: EnvironmentId;
207 readonly tab: PreviewSessionSnapshot;
208 readonly ready: boolean;
209 readonly streaming: boolean;
210 readonly onCommand: PreviewStreamRef["command"];
211 readonly onPaste: () => void;
212}) {
213 const url = browserTabUrl(tab);
214 const [draft, setDraft] = useState<string | null>(null);
215 const submit = () => {
216 const value = draft ?? url;
217 setDraft(null);
218 try {
219 onCommand({ type: "navigate", url: normalizePreviewUrl(value) });
220 } catch {
221 Dialog.alert("Could not open this address", "Enter an http or https URL.");
222 }
223 };
224 return (
225 <View className="flex-row items-center gap-1 px-2 pb-2">
226 <ControlPill
227 icon="chevron.left"
228 accessibilityLabel="Back"
229 disabled={!ready || !tab.canGoBack}
230 onPress={() => onCommand({ type: "history", delta: -1 })}
231 />
232 <ControlPill
233 icon="chevron.right"
234 accessibilityLabel="Forward"
235 disabled={!ready || !tab.canGoForward}
236 onPress={() => onCommand({ type: "history", delta: 1 })}
237 />
238 <TextInput
239 accessibilityLabel="Address"
240 editable={ready}
241 value={draft ?? url}
242 onChangeText={setDraft}
243 onBlur={() => setDraft(null)}
244 onSubmitEditing={submit}
245 placeholder="Enter a URL"
246 placeholderTextColorClassName="accent-placeholder"
247 autoCapitalize="none"
248 autoCorrect={false}
249 keyboardType="url"
250 returnKeyType="go"
251 selectTextOnFocus
252 lineBreakModeIOS="tail"
253 className="h-10 min-w-0 flex-1 rounded-full border border-input-border bg-input px-4 font-sans text-sm text-foreground"
254 />
255 <ControlPill
256 icon="arrow.clockwise"
257 accessibilityLabel="Reload page"
258 disabled={!ready}
259 onPress={() => onCommand({ type: "reload" })}
260 />
261 <BrowserTabMenu
262 environmentId={environmentId}
263 tab={tab}
264 streaming={streaming}
265 onPaste={ready ? onPaste : undefined}
266 />
267 </View>
268 );
269}