| 1 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; |
| 3 | import { |
| 4 | EnvironmentId, |
| 5 | FILL_PREVIEW_VIEWPORT, |
| 6 | ThreadId, |
| 7 | type PreviewSessionSnapshot, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 10 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 11 | import { ActivityIndicator, Alert, AppState, Platform, TextInput, View } from "react-native"; |
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | |
| 14 | import { ControlPill } from "../../components/ControlPill"; |
| 15 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 16 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 17 | import { useThreadServerBrowserTabs } from "../../state/preview"; |
| 18 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 19 | import { BrowserTabMenu } from "./BrowserTabMenu"; |
| 20 | import { browserTabTitle, browserTabUrl, latestBrowserTab } from "./browserTabs"; |
| 21 | import { |
| 22 | PreviewStreamWebView, |
| 23 | type PreviewPictureInPictureState, |
| 24 | type PreviewStreamRef, |
| 25 | } from "./PreviewStreamWebView"; |
| 26 | |
| 27 | const BrowserPreviewStack = createNativeStackNavigator<{ BrowserPreview: undefined }>(); |
| 28 | |
| 29 | const NO_PICTURE_IN_PICTURE: PreviewPictureInPictureState = { supported: false, active: false }; |
| 30 | |
| 31 | type BrowserPreviewRouteScreenProps = StaticScreenProps<{ |
| 32 | readonly environmentId: string; |
| 33 | readonly threadId: string; |
| 34 | readonly tabId?: string; |
| 35 | }>; |
| 36 | |
| 37 | /** The nested native stack supplies the navigation bar inside the modal. */ |
| 38 | export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) { |
| 39 | const navigation = useNavigation(); |
| 40 | const onClose = useCallback(() => navigation.goBack(), [navigation]); |
| 41 | return ( |
| 42 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 43 | <BrowserPreviewStack.Navigator |
| 44 | screenOptions={{ |
| 45 | headerShown: Platform.OS === "ios", |
| 46 | headerBackVisible: false, |
| 47 | headerShadowVisible: false, |
| 48 | headerTransparent: false, |
| 49 | headerTitleStyle: { fontSize: 17, fontWeight: "600" }, |
| 50 | }} |
| 51 | > |
| 52 | <BrowserPreviewStack.Screen name="BrowserPreview"> |
| 53 | {() => ( |
| 54 | <BrowserPreviewScreen |
| 55 | environmentId={EnvironmentId.make(route.params.environmentId)} |
| 56 | threadId={ThreadId.make(route.params.threadId)} |
| 57 | initialTabId={route.params.tabId ?? null} |
| 58 | onClose={onClose} |
| 59 | /> |
| 60 | )} |
| 61 | </BrowserPreviewStack.Screen> |
| 62 | </BrowserPreviewStack.Navigator> |
| 63 | </View> |
| 64 | ); |
| 65 | } |
| 66 | |
| 67 | function BrowserPreviewScreen({ |
| 68 | environmentId, |
| 69 | threadId, |
| 70 | initialTabId, |
| 71 | onClose, |
| 72 | }: { |
| 73 | readonly environmentId: EnvironmentId; |
| 74 | readonly threadId: ThreadId; |
| 75 | readonly initialTabId: string | null; |
| 76 | readonly onClose: () => void; |
| 77 | }) { |
| 78 | const insets = useSafeAreaInsets(); |
| 79 | const { themeVariables } = useAppearancePreferences(); |
| 80 | const focused = useIsFocused(); |
| 81 | const [foreground, setForeground] = useState(AppState.currentState !== "background"); |
| 82 | const [selectedTabId, setSelectedTabId] = useState(initialTabId); |
| 83 | const [pictureInPicture, setPictureInPicture] = useState(NO_PICTURE_IN_PICTURE); |
| 84 | // Address bar commands only reach a page that is streaming. |
| 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 tabId = tab?.tabId ?? null; |
| 91 | // Pin the fallback so another tab's activity does not switch the view. |
| 92 | if (tabId !== null && tabId !== selectedTabId) { |
| 93 | setPictureInPicture(NO_PICTURE_IN_PICTURE); |
| 94 | setSelectedTabId(tabId); |
| 95 | } |
| 96 | useEffect(() => { |
| 97 | const subscription = AppState.addEventListener("change", (state) => |
| 98 | setForeground(state !== "background"), |
| 99 | ); |
| 100 | return () => subscription.remove(); |
| 101 | }, []); |
| 102 | useEffect(() => { |
| 103 | if (focused && loaded && tabs.length === 0) onClose(); |
| 104 | }, [focused, loaded, tabs.length, onClose]); |
| 105 | const onPictureInPicture = useCallback((state: PreviewPictureInPictureState, detail?: string) => { |
| 106 | setPictureInPicture({ supported: state.supported, active: state.active }); |
| 107 | if (detail) Alert.alert("Picture in picture is unavailable", detail); |
| 108 | }, []); |
| 109 | const selectTab = (next: string) => { |
| 110 | setPictureInPicture(NO_PICTURE_IN_PICTURE); |
| 111 | setSelectedTabId(next); |
| 112 | }; |
| 113 | // System picture in picture keeps showing this stream after the app leaves the foreground. |
| 114 | const live = (focused && foreground) || pictureInPicture.active; |
| 115 | |
| 116 | return ( |
| 117 | <View className="flex-1 bg-sheet" style={{ paddingBottom: insets.bottom }}> |
| 118 | <ScreenHeader |
| 119 | title={tab ? browserTabTitle(tab) : "Browser"} |
| 120 | sidebar={false} |
| 121 | onBack={onClose} |
| 122 | options={{ headerBackVisible: false }} |
| 123 | actions={ |
| 124 | // Android WebView cannot play video in picture in picture. |
| 125 | Platform.OS === "ios" && pictureInPicture.supported |
| 126 | ? [ |
| 127 | { |
| 128 | accessibilityLabel: pictureInPicture.active |
| 129 | ? "Exit picture in picture" |
| 130 | : "Picture in picture", |
| 131 | icon: { |
| 132 | ios: pictureInPicture.active ? "pip.exit" : "pip.enter", |
| 133 | android: "visibility", |
| 134 | }, |
| 135 | selected: pictureInPicture.active, |
| 136 | onPress: () => streamRef.current?.togglePictureInPicture(), |
| 137 | }, |
| 138 | ] |
| 139 | : [] |
| 140 | } |
| 141 | menus={ |
| 142 | tabs.length > 1 |
| 143 | ? [ |
| 144 | { |
| 145 | title: "Tabs", |
| 146 | icon: "square.on.square", |
| 147 | items: tabs.map((entry) => ({ |
| 148 | id: entry.tabId, |
| 149 | title: browserTabTitle(entry), |
| 150 | subtitle: browserTabUrl(entry), |
| 151 | selected: entry.tabId === tabId, |
| 152 | onPress: () => selectTab(entry.tabId), |
| 153 | })), |
| 154 | }, |
| 155 | ] |
| 156 | : [] |
| 157 | } |
| 158 | /> |
| 159 | {Platform.OS === "ios" ? ( |
| 160 | <NativeHeaderToolbar placement="left"> |
| 161 | <NativeHeaderToolbar.Button |
| 162 | icon="xmark" |
| 163 | accessibilityLabel="Close browser" |
| 164 | onPress={onClose} |
| 165 | separateBackground |
| 166 | /> |
| 167 | </NativeHeaderToolbar> |
| 168 | ) : null} |
| 169 | {tab ? ( |
| 170 | <> |
| 171 | <BrowserAddressBar |
| 172 | key={`address:${tab.tabId}`} |
| 173 | environmentId={environmentId} |
| 174 | tab={tab} |
| 175 | streaming={streaming} |
| 176 | ready={streaming && canControl} |
| 177 | onCommand={(input) => streamRef.current?.command(input)} |
| 178 | /> |
| 179 | {live ? ( |
| 180 | <PreviewStreamWebView |
| 181 | key={`stream:${tab.tabId}`} |
| 182 | environmentId={environmentId} |
| 183 | threadId={threadId} |
| 184 | tabId={tab.tabId} |
| 185 | ref={streamRef} |
| 186 | interactive |
| 187 | background={themeVariables["--color-sheet-solid"]} |
| 188 | onPictureInPicture={onPictureInPicture} |
| 189 | onStreamingChange={setStreaming} |
| 190 | onControl={(control) => setCanControl(control?.controller === "you")} |
| 191 | /> |
| 192 | ) : ( |
| 193 | <View className="flex-1" /> |
| 194 | )} |
| 195 | </> |
| 196 | ) : ( |
| 197 | <View className="flex-1 items-center justify-center"> |
| 198 | {focused && foreground ? ( |
| 199 | <ActivityIndicator color={themeVariables["--color-icon"]} /> |
| 200 | ) : null} |
| 201 | </View> |
| 202 | )} |
| 203 | </View> |
| 204 | ); |
| 205 | } |
| 206 | |
| 207 | function BrowserAddressBar({ |
| 208 | environmentId, |
| 209 | tab, |
| 210 | ready, |
| 211 | streaming, |
| 212 | onCommand, |
| 213 | }: { |
| 214 | readonly environmentId: EnvironmentId; |
| 215 | readonly tab: PreviewSessionSnapshot; |
| 216 | /** The viewer controls the page, so navigation and reload apply. */ |
| 217 | readonly ready: boolean; |
| 218 | /** The menu's changes need no control, only a live tab. */ |
| 219 | readonly streaming: boolean; |
| 220 | readonly onCommand: PreviewStreamRef["command"]; |
| 221 | }) { |
| 222 | const url = browserTabUrl(tab); |
| 223 | // Null while not editing, so agent navigation keeps the field current. |
| 224 | const [draft, setDraft] = useState<string | null>(null); |
| 225 | const submit = () => { |
| 226 | const value = draft ?? url; |
| 227 | setDraft(null); |
| 228 | try { |
| 229 | onCommand({ type: "navigate", url: normalizePreviewUrl(value) }); |
| 230 | } catch { |
| 231 | Alert.alert("Could not open this address", "Enter an http or https URL."); |
| 232 | } |
| 233 | }; |
| 234 | return ( |
| 235 | <View className="flex-row items-center gap-1 px-2 pb-2"> |
| 236 | <ControlPill |
| 237 | icon="chevron.left" |
| 238 | accessibilityLabel="Back" |
| 239 | disabled={!ready || !tab.canGoBack} |
| 240 | onPress={() => onCommand({ type: "history", delta: -1 })} |
| 241 | /> |
| 242 | <ControlPill |
| 243 | icon="chevron.right" |
| 244 | accessibilityLabel="Forward" |
| 245 | disabled={!ready || !tab.canGoForward} |
| 246 | onPress={() => onCommand({ type: "history", delta: 1 })} |
| 247 | /> |
| 248 | <TextInput |
| 249 | accessibilityLabel="Address" |
| 250 | editable={ready} |
| 251 | value={draft ?? url} |
| 252 | onChangeText={setDraft} |
| 253 | onBlur={() => setDraft(null)} |
| 254 | onSubmitEditing={submit} |
| 255 | placeholder="Enter a URL" |
| 256 | placeholderTextColorClassName="accent-placeholder" |
| 257 | autoCapitalize="none" |
| 258 | autoCorrect={false} |
| 259 | keyboardType="url" |
| 260 | returnKeyType="go" |
| 261 | selectTextOnFocus |
| 262 | className="h-10 min-w-0 flex-1 rounded-full border border-input-border bg-input px-4 font-sans text-sm text-foreground" |
| 263 | /> |
| 264 | <ControlPill |
| 265 | icon="arrow.clockwise" |
| 266 | accessibilityLabel="Reload page" |
| 267 | disabled={!ready} |
| 268 | onPress={() => onCommand({ type: "reload" })} |
| 269 | /> |
| 270 | <BrowserTabMenu environmentId={environmentId} tab={tab} disabled={!streaming} /> |
| 271 | </View> |
| 272 | ); |
| 273 | } |