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 @@ -10,7 +10,7 @@ import { createStaticNavigation, getStateFromPath } from "@react-navigation/nati

import { RegistryContext } from "@effect/atom-react";
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 { IncomingShareProvider } from "./features/sharing/IncomingShareProvider";
Expand Down Expand Up @@ -108,7 +108,7 @@ function AppContent() {
<IncomingShareProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
</IncomingShareProvider>
<Dialog.Host />
<DialogHost />
<ThreadArrangementHost />
</NativeLayoutMetricsProvider>
</GlobalVoiceInputControl>
Expand Down
25 changes: 11 additions & 14 deletions apps/mobile/src/components/ui/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Alert } from "react-native";
import type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types";

export type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types";

/** Informational and multi-action alerts share the same platform presenter. */
export function alert(...args: Parameters<typeof Alert.alert>): void {
Alert.alert(...args);
Expand All @@ -19,20 +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);
}

/** iOS alerts are presented directly by React Native. */
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;
}
102 changes: 102 additions & 0 deletions apps/mobile/src/components/ui/DialogHost.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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: Slot,
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", () => {
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).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");
});
118 changes: 118 additions & 0 deletions apps/mobile/src/components/ui/DialogHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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";

/** iOS presenter for Dialog.prompt; alerts and confirms are presented directly by React Native. */
export function DialogHost() {
const [presented, setPresented] = useState<
(TextInputDialogRequest & { readonly id: number }) | null
>(null);
const nextPresentationId = useRef(0);
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 settled = useRef(false);
const [value, setValue] = useState(request.initialValue);
// A second tap can land before the modal unmounts; only the first one counts.
const settle = (run: () => void) => {
if (settled.current) return;
settled.current = true;
props.onClose();
run();
};
const cancel = () => settle(() => request.onCancel?.());
const submit = () => settle(() => request.onConfirm(value));

return (
<Modal
visible
transparent
animationType="fade"
onShow={() => inputRef.current?.focus()}
onRequestClose={cancel}
>
<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 @@ -103,6 +104,7 @@ export function ThreadPullRequestsSheet(
</Toolbar>
) : null}
{content}
<DialogSheetHost />
</View>
);
}
Expand Down
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 @@ -69,7 +70,12 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
if (isBlankLink) navigation.goBack();
}, [isBlankLink, navigation]);
if (isBlankLink) return null;
return <GitOverviewSheetContent {...props} />;
return (
<>
<GitOverviewSheetContent {...props} />
<DialogSheetHost />
</>
);
}

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 @@ -3,6 +3,7 @@ import "vite-plus/test/config";

const reactTests = [
"src/components/ui/Dialog.android.test.tsx",
"src/components/ui/DialogHost.test.tsx",
"src/features/settings/SettingsServerControlsRouteScreen.test.tsx",
"src/features/settings/components/BranchNamingSettings.test.tsx",
"src/native/NativeHeaderToolbar.test.tsx",
Expand Down
Loading