diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 225496bb7a407..a9efe5f643214 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -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"; @@ -240,7 +240,7 @@ function AppContent() { theme={navigationTheme} onStateChange={handleNavigationStateChange} /> - + diff --git a/apps/mobile/src/components/ui/Dialog.tsx b/apps/mobile/src/components/ui/Dialog.tsx index afef71264cc6d..778d5d7464137 100644 --- a/apps/mobile/src/components/ui/Dialog.tsx +++ b/apps/mobile/src/components/ui/Dialog.tsx @@ -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); + }; } diff --git a/apps/mobile/src/components/ui/DialogHost.android.tsx b/apps/mobile/src/components/ui/DialogHost.android.tsx new file mode 100644 index 0000000000000..224010985a672 --- /dev/null +++ b/apps/mobile/src/components/ui/DialogHost.android.tsx @@ -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; +} diff --git a/apps/mobile/src/components/ui/DialogHost.test.tsx b/apps/mobile/src/components/ui/DialogHost.test.tsx new file mode 100644 index 0000000000000..7c1ac19255116 --- /dev/null +++ b/apps/mobile/src/components/ui/DialogHost.test.tsx @@ -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 }) =>
{props.children}
; + return { + Alert: { alert: vi.fn() }, + KeyboardAvoidingView: Slot, + Modal: (props: { + readonly children?: ReactNode; + readonly visible: boolean; + readonly onDismiss?: () => void; + readonly onRequestClose?: () => void; + }) => ( +
+ {props.visible ? props.children : null} + + +
+ ), + ScrollView: Slot, + View: Slot, + useWindowDimensions: () => ({ width: 390, height: 844 }), + }; +}); +vi.mock("./AppText", () => ({ + AppText: (props: { readonly children?: ReactNode }) => {props.children}, + AppTextInput: (props: { readonly value: string; readonly onChangeText: (v: string) => void }) => ( + props.onChangeText(event.target.value)} /> + ), +})); +vi.mock("./Button", () => ({ + Button: (props: { readonly label: string; readonly onPress: () => void }) => ( + + ), +})); +vi.mock("./GlassSurface", () => ({ + GlassSurface: (props: { readonly children?: ReactNode }) =>
{props.children}
, +})); +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 = () => ) { + 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(() => ); + + 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"); + }, +); diff --git a/apps/mobile/src/components/ui/DialogHost.tsx b/apps/mobile/src/components/ui/DialogHost.tsx new file mode 100644 index 0000000000000..2a22aa6811c6a --- /dev/null +++ b/apps/mobile/src/components/ui/DialogHost.tsx @@ -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 ? ( + 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(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 ( + inputRef.current?.focus()} + onRequestClose={cancel} + onDismiss={dismissed} + > + + + + + {request.title} + + {request.message ? ( + + {request.message} + + ) : null} + + + +