Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions apps/mobile/src/features/browser/BrowserPreviewRouteScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// @vitest-environment jsdom
import { act, useEffect, useImperativeHandle, type ComponentProps, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { EnvironmentId, ThreadId, PreviewTabId } from "@t3tools/contracts";
import { expect, it, vi } from "vite-plus/test";
const observed = vi.hoisted(() => ({ command: vi.fn(), alert: vi.fn() }));
vi.mock("react-native", () => ({
Platform: { OS: "android" },
AppState: { currentState: "active", addEventListener: () => ({ remove() {} }) },
Alert: { alert: observed.alert },
View: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Pressable: ({
children,
onPress,
disabled,
}: {
children?: ReactNode;
onPress?: () => void;
disabled?: boolean;
}) => (
<button disabled={disabled} onClick={onPress}>
{children}
</button>
),
TextInput: ({
value,
onChangeText,
onSubmitEditing,
accessibilityLabel,
editable,
}: {
value: string;
onChangeText: (value: string) => void;
onSubmitEditing: () => void;
accessibilityLabel: string;
editable: boolean;
}) => (
<input
aria-label={accessibilityLabel}
value={value}
disabled={!editable}
onInput={(event) => onChangeText(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") onSubmitEditing();
}}
/>
),
}));
vi.mock("@react-navigation/native", () => ({
useIsFocused: () => true,
useNavigation: () => ({ goBack() {} }),
}));
vi.mock("@react-navigation/native-stack", () => ({
createNativeStackNavigator: () => ({
Navigator: ({ children }: { children: ReactNode }) => children,
Screen: ({ children }: { children: () => ReactNode }) => children(),
}),
}));
vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) }));
vi.mock("../../state/preview", () => ({ useThreadServerBrowserTabs: () => null }));
vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({
useAppearancePreferences: () => ({ themeVariables: {} }),
}));
vi.mock("../../components/ui/Toolbar", () => ({ Toolbar: () => null }));
vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null }));
vi.mock("../../components/ui/ControlPill", () => ({ ControlPill: () => null }));
vi.mock("../../components/ui/AppBar", () => ({ AppBar: () => null }));
vi.mock("../../components/ui/AppText", () => ({
AppText: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}));
vi.mock("./BrowserTabMenu", () => ({ BrowserTabMenu: () => null }));
vi.mock("./PreviewStreamWebView", () => ({
PreviewStreamWebView: (
props: ComponentProps<typeof import("./PreviewStreamWebView").PreviewStreamWebView>,
) => {
useImperativeHandle(
props.ref,
() => ({ command: observed.command, togglePictureInPicture() {} }),
[],
);
useEffect(() => {
props.onStreamingChange?.(true);
props.onControl?.({ canOperate: true, controller: "you", generation: 1, dialog: null });
}, []);
return null;
},
}));
import { BrowserPreviewRouteScreen } from "./BrowserPreviewRouteScreen";
it("qualified navigation reports invalid addresses and sends only normalized valid input", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
observed.command.mockClear();
observed.alert.mockClear();
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const errors: unknown[] = [];
const error = (event: ErrorEvent) => {
errors.push(event.error);
event.preventDefault();
};
window.addEventListener("error", error);
try {
await act(async () =>
root.render(
<BrowserPreviewRouteScreen
route={{
params: {
environmentId: "source-A",
threadId: "destination-thread",
resource: {
type: "preview",
environmentId: EnvironmentId.make("destination-B"),
threadId: ThreadId.make("destination-thread"),
tabId: PreviewTabId.make("tab"),
serverEpoch: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
},
},
}}
/>,
),
);
const input = container.querySelector<HTMLInputElement>("input")!;
const go = [...container.querySelectorAll("button")].find(
(button) => button.textContent === "Go",
)!;
expect(input.disabled).toBe(false);
const enter = async (value: string) =>
act(async () => {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
});
for (const [index, value] of ["http://", "file:///private/test"].entries()) {
await enter(value);
await act(async () => {
if (index === 0) go.click();
else input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(observed.command).not.toHaveBeenCalled();
expect(observed.alert).toHaveBeenLastCalledWith(
"Could not open this address",
"Enter an http or https URL.",
);
}
expect(errors).toEqual([]);
await enter("localhost:3000");
await act(async () => go.click());
expect(observed.command.mock.calls).toEqual([
[{ type: "navigate", url: "http://localhost:3000/" }],
]);
} finally {
await act(async () => root.unmount());
window.removeEventListener("error", error);
container.remove();
vi.unstubAllGlobals();
}
});
183 changes: 171 additions & 12 deletions apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@ import * as Dialog from "../../components/ui/Dialog";
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import {
PreviewViewerResource,
EnvironmentId,
FILL_PREVIEW_VIEWPORT,
ThreadId,
type PreviewSessionSnapshot,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import * as Option from "effect/Option";
import { normalizePreviewUrl } from "@t3tools/shared/preview";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, Platform, TextInput, View } from "react-native";
import { AppState, Platform, TextInput, View, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AppText } from "../../components/ui/AppText";
import { ControlPill } from "../../components/ui/ControlPill";
import { AppBar } from "../../components/ui/AppBar";

import { useThreadServerBrowserTabs } from "../../state/preview";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { BrowserTabMenu } from "./BrowserTabMenu";
Expand All @@ -35,11 +36,20 @@ type BrowserPreviewRouteScreenProps = StaticScreenProps<{
readonly environmentId: string;
readonly threadId: string;
readonly tabId?: string;
readonly resource?: PreviewViewerResource;
}>;

export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) {
const navigation = useNavigation();
const onClose = useCallback(() => navigation.goBack(), [navigation]);
const resource =
route.params.resource === undefined
? undefined
: Option.getOrNull(Schema.decodeUnknownOption(PreviewViewerResource)(route.params.resource));
useEffect(() => {
if (resource === null) onClose();
}, [resource, onClose]);
if (resource === null) return null;
return (
<View collapsable={false} className="flex-1 bg-sheet">
<BrowserPreviewStack.Navigator
Expand All @@ -52,20 +62,169 @@ export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenPr
}}
>
<BrowserPreviewStack.Screen name="BrowserPreview">
{() => (
<BrowserPreviewScreen
environmentId={EnvironmentId.make(route.params.environmentId)}
threadId={ThreadId.make(route.params.threadId)}
initialTabId={route.params.tabId ?? null}
onClose={onClose}
/>
)}
{() =>
resource === undefined ? (
<BrowserPreviewScreen
environmentId={EnvironmentId.make(route.params.environmentId)}
threadId={ThreadId.make(route.params.threadId)}
initialTabId={route.params.tabId ?? null}
onClose={onClose}
/>
) : (
<QualifiedBrowserPreviewScreen
environmentId={EnvironmentId.make(route.params.environmentId)}
resource={resource}
onClose={onClose}
/>
)
}
</BrowserPreviewStack.Screen>
</BrowserPreviewStack.Navigator>
</View>
);
}

function QualifiedBrowserPreviewScreen({
environmentId,
resource,
onClose,
}: {
readonly environmentId: EnvironmentId;
readonly resource: PreviewViewerResource;
readonly onClose: () => void;
}) {
const focused = useIsFocused();
const { themeVariables } = useAppearancePreferences();
const [foreground, setForeground] = useState(AppState.currentState !== "background");
const [selected, setSelected] = useState(resource);
const [tabs, setTabs] = useState([resource]);
const [address, setAddress] = useState("");
const [canControl, setCanControl] = useState(false);
const [streaming, setStreaming] = useState(false);
const [pictureInPicture, setPictureInPicture] = useState(NO_PICTURE_IN_PICTURE);
const streamRef = useRef<PreviewStreamRef>(null);
useEffect(() => {
const subscription = AppState.addEventListener("change", (state) =>
setForeground(state !== "background"),
);
return () => subscription.remove();
}, []);
useEffect(() => {
setSelected(resource);
setTabs([resource]);
}, [resource]);
const navigate = () => {
if (!canControl || !streaming || !address.trim()) return;
const stream = streamRef.current;
if (stream === null) return;
try {
stream.command({ type: "navigate", url: normalizePreviewUrl(address) });
} catch {
Dialog.alert("Could not open this address", "Enter an http or https URL.");
}
};
return (
<View className="flex-1 bg-sheet">
<AppBar
title="Browser"
sidebar={false}
onBack={onClose}
options={{ headerBackVisible: false }}
actions={
Platform.OS === "ios" && pictureInPicture.supported
? [
{
accessibilityLabel: pictureInPicture.active
? "Exit picture in picture"
: "Picture in picture",
icon: {
ios: pictureInPicture.active ? "pip.exit" : "pip.enter",
android: "visibility",
},
onPress: () => streamRef.current?.togglePictureInPicture(),
},
]
: []
}
menus={
tabs.length > 1
? [
{
title: "Tabs",
icon: "square.on.square",
items: tabs.map((tab, index) => ({
id: `tab-${index}`,
title: `Browser ${index + 1}`,
selected: tab.tabId === selected.tabId,
onPress: () => {
setSelected(tab);
setPictureInPicture(NO_PICTURE_IN_PICTURE);
},
})),
},
]
: []
}
/>
<View className="flex-row items-center gap-3 px-4 py-2">
<Pressable accessibilityRole="button" onPress={onClose}>
<AppText>Close view</AppText>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={!streaming || !canControl}
onPress={() => streamRef.current?.command({ type: "history", delta: -1 })}
>
<AppText>Back</AppText>
</Pressable>
<TextInput
className="flex-1 text-foreground"
accessibilityLabel="Browser address"
placeholder="Address"
value={address}
onChangeText={setAddress}
onSubmitEditing={navigate}
autoCapitalize="none"
autoCorrect={false}
editable={streaming && canControl}
/>
<Pressable
accessibilityRole="button"
disabled={!streaming || !canControl}
onPress={navigate}
>
<AppText>Go</AppText>
</Pressable>
</View>
<PreviewStreamWebView
key={`${selected.environmentId}:${selected.threadId}:${selected.tabId}:${selected.serverEpoch}`}
environmentId={environmentId}
resource={selected}
threadId={selected.threadId}
tabId={selected.tabId}
paused={!(focused || pictureInPicture.active)}
backgrounded={!(foreground || pictureInPicture.active)}
ref={streamRef}
interactive
background={themeVariables["--color-sheet-solid"]}
onPictureInPicture={(state, detail) => {
setPictureInPicture(state);
if (detail) Dialog.alert("Picture in picture is unavailable", detail);
}}
onStreamingChange={setStreaming}
onControl={(control) => setCanControl(control?.controller === "you")}
onPopup={(popup) => {
setTabs((current) =>
current.some((tab) => tab.tabId === popup.tabId) ? current : [...current, popup],
);
setSelected(popup);
setPictureInPicture(NO_PICTURE_IN_PICTURE);
}}
/>
</View>
);
}

function BrowserPreviewScreen({
environmentId,
threadId,
Expand Down
Loading
Loading