Skip to content
Draft
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
4 changes: 2 additions & 2 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { pipe } from "effect/Function";
import { RegistryContext } from "@effect/atom-react";
import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages";
import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet";
import * as Dialog from "./components/ui/Dialog";
import { DialogHost } from "./components/ui/DialogHost";
import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
import { installShowcaseCapture } from "./features/showcase/ShowcaseCaptureCoordinator";
Expand Down Expand Up @@ -240,7 +240,7 @@ function AppContent() {
theme={navigationTheme}
onStateChange={handleNavigationStateChange}
/>
<Dialog.Host />
<DialogHost />
<ThreadArrangementHost />
<ProjectChipIconRasterizer />
</NativeLayoutMetricsProvider>
Expand Down
23 changes: 11 additions & 12 deletions apps/mobile/src/components/ui/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,18 @@ export function confirm(request: ConfirmDialogRequest): void {
]);
}

// The most recently mounted host presents, so a sheet's host wins while the sheet is open.
const promptPresenters: Array<(request: TextInputDialogRequest) => void> = [];

// UIAlertController's text field lays out past the alert's edge on iOS 26 (Liquid Glass),
// so prompts render in-app through DialogHost instead of through Alert.prompt.
export function prompt(request: TextInputDialogRequest): void {
Alert.prompt(
request.title,
request.message,
[
{ text: request.cancelText ?? "Cancel", style: "cancel", onPress: request.onCancel },
{ text: request.confirmText, onPress: (value) => request.onConfirm(value ?? "") },
],
"plain-text",
request.initialValue,
);
promptPresenters.at(-1)?.(request);
}

export function Host() {
return null;
export function registerPromptPresenter(present: (request: TextInputDialogRequest) => void) {
promptPresenters.push(present);
return () => {
promptPresenters.splice(promptPresenters.indexOf(present), 1);
};
}
6 changes: 6 additions & 0 deletions apps/mobile/src/components/ui/DialogHost.android.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { Host as DialogHost } from "./Dialog.android";

/** Compose dialogs open in their own window, so sheets need no extra host on Android. */
export function DialogSheetHost() {
return null;
}
153 changes: 153 additions & 0 deletions apps/mobile/src/components/ui/DialogHost.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, expect, it, vi } from "vite-plus/test";

vi.mock("react-native", () => {
const Slot = (props: { readonly children?: ReactNode }) => <div>{props.children}</div>;
return {
Alert: { alert: vi.fn() },
KeyboardAvoidingView: Slot,
Modal: (props: {
readonly children?: ReactNode;
readonly visible: boolean;
readonly onDismiss?: () => void;
readonly onRequestClose?: () => void;
}) => (
<div>
{props.visible ? props.children : null}
<button onClick={props.onDismiss}>Dismiss modal</button>
<button onClick={props.onRequestClose}>Request close</button>
</div>
),
ScrollView: Slot,
View: Slot,
useWindowDimensions: () => ({ width: 390, height: 844 }),
};
});
vi.mock("./AppText", () => ({
AppText: (props: { readonly children?: ReactNode }) => <span>{props.children}</span>,
AppTextInput: (props: { readonly value: string; readonly onChangeText: (v: string) => void }) => (
<input value={props.value} onChange={(event) => props.onChangeText(event.target.value)} />
),
}));
vi.mock("./Button", () => ({
Button: (props: { readonly label: string; readonly onPress: () => void }) => (
<button onClick={props.onPress}>{props.label}</button>
),
}));
vi.mock("./GlassSurface", () => ({
GlassSurface: (props: { readonly children?: ReactNode }) => <div>{props.children}</div>,
}));
vi.mock("../../features/settings/appearance/AppearancePreferencesProvider", () => ({
useAppearancePreferences: () => ({ themeVariables: {} }),
}));

import * as Dialog from "./Dialog";
import { DialogHost, DialogSheetHost } from "./DialogHost";

const roots: Root[] = [];

function mountHost(host: () => ReactNode = () => <DialogHost />) {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
roots.push(root);
act(() => root.render(host()));
return { root, container };
}

function button(container: HTMLElement, label: string) {
return [...container.querySelectorAll("button")].find((node) => node.textContent === label)!;
}

function promptRename(onConfirm: (value: string) => void) {
act(() =>
Dialog.prompt({
title: "Rename thread",
initialValue: "old",
confirmText: "Rename",
onConfirm,
}),
);
}

afterEach(() => {
for (const root of roots.splice(0)) act(() => root.unmount());
document.body.innerHTML = "";
});

it("confirms the edited value exactly once after native dismissal", () => {
const { container } = mountHost();
const onConfirm = vi.fn();
promptRename(onConfirm);

const input = container.querySelector("input")!;
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "new");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const rename = button(container, "Rename");
act(() => {
rename.click();
rename.click();
});

expect(onConfirm).not.toHaveBeenCalled();
const dismiss = button(container, "Dismiss modal");
act(() => {
dismiss.click();
dismiss.click();
});
expect(onConfirm).toHaveBeenCalledExactlyOnceWith("new");
expect(container.textContent).toBe("");
});

it("presents from a mounted sheet host, then falls back to the root host", () => {
const app = mountHost();
const sheet = mountHost(() => <DialogSheetHost />);

promptRename(vi.fn());
expect(sheet.container.textContent).toContain("Rename thread");
expect(app.container.textContent).toBe("");
act(() => button(sheet.container, "Cancel").click());

roots.splice(roots.indexOf(sheet.root), 1);
act(() => sheet.root.unmount());
promptRename(vi.fn());
expect(app.container.textContent).toContain("Rename thread");
});

it.each(["Cancel", "Request close"])(
"waits for dismissal before %s opens another dialog",
(action) => {
const { container } = mountHost();
const onCancel = vi.fn(() =>
Dialog.prompt({
title: "Second prompt",
initialValue: "",
confirmText: "Save",
onConfirm: vi.fn(),
}),
);
act(() =>
Dialog.prompt({
title: "First prompt",
initialValue: "",
confirmText: "Save",
onConfirm: vi.fn(),
onCancel,
}),
);
act(() => button(container, action).click());
expect(onCancel).not.toHaveBeenCalled();
const dismiss = button(container, "Dismiss modal");
act(() => {
dismiss.click();
dismiss.click();
});
expect(onCancel).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("Second prompt");
expect(container.textContent).not.toContain("First prompt");
},
);
127 changes: 127 additions & 0 deletions apps/mobile/src/components/ui/DialogHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from "react";
import {
KeyboardAvoidingView,
Modal,
ScrollView,
useWindowDimensions,
View,
type TextInputInstance,
} from "react-native";

import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider";
import { AppText, AppTextInput } from "./AppText";
import { Button } from "./Button";
import { registerPromptPresenter, type TextInputDialogRequest } from "./Dialog";
import { GlassSurface } from "./GlassSurface";

export function DialogHost() {
const [presented, setPresented] = useState<
(TextInputDialogRequest & { readonly id: number }) | null
>(null);
const nextPresentationId = useRef(0);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Register the native prompt presenter for this host's lifetime.
useEffect(() => {
const present = (request: TextInputDialogRequest) =>
setPresented({ ...request, id: ++nextPresentationId.current });
return registerPromptPresenter(present);
}, []);

return presented ? (
<PromptDialog key={presented.id} request={presented} onClose={() => setPresented(null)} />
) : null;
}

/**
* A React Native Modal presents from its nearest view controller, and the root one cannot present
* over a native sheet. Mount this inside sheets that open prompts.
*/
export const DialogSheetHost = DialogHost;

function PromptDialog(props: {
readonly request: TextInputDialogRequest;
readonly onClose: () => void;
}) {
const { request } = props;
const { width, height, fontScale } = useWindowDimensions();
const stackActions = fontScale > 1.3;
const { themeVariables } = useAppearancePreferences();
const inputRef = useRef<TextInputInstance>(null);
const pendingAction = useRef<(() => void) | null>(null);
const [visible, setVisible] = useState(true);
const [value, setValue] = useState(request.initialValue);
const settle = (run: () => void) => {
if (pendingAction.current) return;
pendingAction.current = run;
setVisible(false);
};
const dismissed = () => {
const run = pendingAction.current;
if (!run) return;
pendingAction.current = null;
props.onClose();
// Callbacks can present another native alert or sheet only after UIKit dismisses this modal.
run();
};
const cancel = () => settle(() => request.onCancel?.());
const submit = () => settle(() => request.onConfirm(value));

return (
<Modal
visible={visible}
transparent
animationType="fade"
supportedOrientations={["portrait", "portrait-upside-down", "landscape"]}
onShow={() => inputRef.current?.focus()}
onRequestClose={cancel}
onDismiss={dismissed}
>
<KeyboardAvoidingView
behavior="padding"
className="flex-1 items-center justify-center bg-backdrop p-6"
>
<GlassSurface
accessibilityViewIsModal
className="bg-sheet"
tintColorClassName="accent-sheet/20"
fallbackColor={themeVariables["--color-sheet"]}
style={{ width: Math.min(340, width - 48), maxHeight: height - 96, borderRadius: 28 }}
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerClassName="gap-4 p-5"
alwaysBounceVertical={false}
>
<AppText accessibilityRole="header" textRole="title" className="text-center">
{request.title}
</AppText>
{request.message ? (
<AppText textRole="footnote" className="text-center text-foreground-muted">
{request.message}
</AppText>
) : null}
<AppTextInput
ref={inputRef}
accessibilityLabel={request.title}
placeholder={request.placeholder}
value={value}
onChangeText={setValue}
selectTextOnFocus
clearButtonMode="while-editing"
returnKeyType="done"
submitBehavior="submit"
onSubmitEditing={submit}
/>
<View className={stackActions ? "flex-col-reverse gap-3" : "flex-row gap-3"}>
<View className={stackActions ? undefined : "flex-1"}>
<Button fullWidth label={request.cancelText ?? "Cancel"} onPress={cancel} />
</View>
<View className={stackActions ? undefined : "flex-1"}>
<Button fullWidth tone="primary" label={request.confirmText} onPress={submit} />
</View>
</View>
</ScrollView>
</GlassSurface>
</KeyboardAvoidingView>
</Modal>
);
}
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AndroidSheetHeader } from "../../components/ui/AndroidScreenHeader";
import { AppText as Text } from "../../components/ui/AppText";
import { DialogSheetHost } from "../../components/ui/DialogHost";
import { ScreenContent } from "../../components/ui/ScreenContent";
import { SheetHeader } from "../../components/ui/SheetHeader";
import { Toolbar } from "../../components/ui/Toolbar";
Expand Down Expand Up @@ -104,6 +105,7 @@ export function ThreadPullRequestsSheet(
</Toolbar>
) : null}
{content}
<DialogSheetHost />
</View>
);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Dialog from "../../components/ui/Dialog";
import { DialogSheetHost } from "../../components/ui/DialogHost";
import { useRememberThreadProject } from "./useRememberThreadProject";
import { useOpenPullRequest } from "../../native/useOpenPullRequest";
import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
Expand Down Expand Up @@ -1114,6 +1115,7 @@ export function FullscreenThreadRouteScreen(
route={props.route}
inspectorPresentation={{ onClose: () => navigation.goBack() }}
/>
<DialogSheetHost />
</InspectorContentScope>
);
}
8 changes: 7 additions & 1 deletion apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Dialog from "../../../components/ui/Dialog";
import { DialogSheetHost } from "../../../components/ui/DialogHost";
import { SheetHeader } from "../../../components/ui/SheetHeader";
import { useOpenPullRequest } from "../../../native/useOpenPullRequest";
import {
Expand Down Expand Up @@ -70,7 +71,12 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
if (isBlankLink) navigation.goBack();
}, [isBlankLink, navigation]);
if (isBlankLink) return null;
return <GitOverviewSheetContent {...props} />;
return (
<>
<GitOverviewSheetContent {...props} />
{props.presentation !== "inspector" ? <DialogSheetHost /> : null}
</>
);
}

function GitOverviewSheetContent(props: GitOverviewSheetProps) {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const reactTests = [
"src/features/pull-requests/usePullRequestDiff.test.tsx",
"src/components/ProjectFavicon.test.tsx",
"src/components/ui/Dialog.android.test.tsx",
"src/components/ui/DialogHost.test.tsx",
"src/features/files/ZipArchiveView.test.tsx",
"src/features/pull-requests/guide/useReviewGuide.test.tsx",
"src/features/settings/SettingsServerControlsRouteScreen.test.tsx",
Expand Down
Loading