diff --git a/packages/app/e2e/mobile/modal-sheet/README.md b/packages/app/e2e/mobile/modal-sheet/README.md index d95034dad4..8a59b16883 100644 --- a/packages/app/e2e/mobile/modal-sheet/README.md +++ b/packages/app/e2e/mobile/modal-sheet/README.md @@ -90,3 +90,52 @@ a failed provider tap leaves its modal open, so dismiss it before the next run. journeys remain outside the default mobile suite, alongside the compact sheet journeys above. Keep generated screenshots, recordings, and run logs outside the repository. + +## Stacked adaptive sheets + +`stacked-pairing.ad` opens the welcome screen's pairing sheet and then the root-mounted host +confirmation. It first types without tapping the input to verify autofocus, then cancels the +confirmation, closes and reopens the underlying sheet, confirms again, +and completes the password retry. `stacked-settings.ad` checks the same return and retry through +Settings → add host → Direct connection → Advanced. Run both on phones and tablets so both the +bottom sheet and centered dialog are exercised. + +These integration journeys require the host-confirmation flow in the test app. Include both the +sheet fix and that flow in the QA checkout when verifying them; the production sheet component +has no dependency on the host-confirmation feature. + +Use a fresh English app with notification permission granted and first-use keyboard prompts +already dismissed. Load this checkout from an isolated Metro. Start an isolated password-protected +daemon and a local Wrangler relay with `--var 'PASEO_RELAY_UPSTREAM:'`; the checked-in upstream +setting otherwise forwards traffic to the hosted relay, which drops password rejection replies. +Set `PAIRING_LINK` to the daemon's `relay://` URI with an intentionally incorrect `password` query +parameter, and `PASSWORD` to its correct password. This exercises password retry even while the +daemon accepts relay connections with no credential. + +```sh +agent-device replay packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad \ + --state-dir "$SHEET_QA_DEVICE_STATE" --session stacked-ipad --platform ios \ + --udid "$SHEET_QA_IOS_UDID" \ + --env PAIRING_LINK="$SHEET_QA_LINK" --env PASSWORD="$SHEET_QA_PASSWORD" \ + --env OUT="$SHEET_QA_OUTPUT" +``` + +For Settings, first open Settings with Add host visible. With a saved host, open Switch host +first. Use a second loopback spelling +(`localhost` instead of `127.0.0.1`) for the same local relay so the saved host asks for confirmation +of its changed connection. Pass that link to `stacked-settings.ad` with the same arguments. +For an app with no saved hosts, use the original link; Add host is visible directly in Settings. + +Android uses the same journeys: copy each script outside the repository, change its context's +`platform=ios` to `platform=android`, and pass `--serial` instead of `--udid`. Reverse both Metro +(including the port advertised by its manifest) and the local relay ports. Keep driver state, +artifacts, and sessions separate from other runs. These host-dependent journeys remain outside the +default mobile suite, alongside the existing modal-sheet scripts. + +On iPhone, bulk replacement or bulk keyboard entry of the advanced URI field triggers a +repeated-update error with both the parent and stacked-sheet implementations. Entering the complete +URI one character at a time passed with both implementations; faster typing remains unverified. +On iPad, replacing the field a second time appends text. For this Settings journey, use the +native clipboard Paste menu, then continue the assertions manually. Dismiss the password keyboard +with Done to submit when the Connect button is below the keyboard. The welcome pairing regression +runs end to end without these adjustments. diff --git a/packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad b/packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad new file mode 100644 index 0000000000..d51d3ce060 --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad @@ -0,0 +1,50 @@ +# Fresh English app at Welcome, local relay host with PASSWORD, see README.md. +# Run on phones and tablets: the confirmation lives at the root, outside the pairing sheet. +context platform=ios timeout=180000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"welcome-paste-pairing-link\"" 20000 +screenshot "${OUT}/01-welcome.png" +press "id=\"welcome-paste-pairing-link\"" +wait stable +# Type without tapping the input: opening the sheet must retain its autofocus. +wait "id=\"pair-link-input\"" 10000 +type "relay://" +wait "id=\"pair-link-input\" value=\"relay://\"" 5000 +screenshot "${OUT}/20-pairing-autofocus-without-tap.png" +fill "id=\"pair-link-input\"" "${PAIRING_LINK}" +wait stable +screenshot "${OUT}/02-pairing-input.png" +press "id=\"pair-link-submit\"" +wait stable +wait "id=\"host-confirmation-connect\"" 10000 +screenshot "${OUT}/03-confirmation-on-top.png" +press "id=\"host-confirmation-cancel\"" +wait stable +wait "id=\"pair-link-submit\" enabled=true" 10000 +screenshot "${OUT}/04-cancel-returns-to-pairing.png" +# Close and reopen the underlying sheet, then edit it and submit again. +press "id=\"pair-link-cancel\"" +wait stable +wait "id=\"welcome-paste-pairing-link\"" 10000 +screenshot "${OUT}/05-underlying-sheet-closes.png" +press "id=\"welcome-paste-pairing-link\"" +wait stable +wait "id=\"pair-link-input\"" 10000 +fill "id=\"pair-link-input\"" "${PAIRING_LINK}" +wait stable +press "id=\"pair-link-submit\"" +wait stable +wait "id=\"host-confirmation-connect\"" 10000 +screenshot "${OUT}/06-confirmation-reopens.png" +press "id=\"host-confirmation-connect\"" +wait stable +wait "id=\"pair-link-password-input\"" 20000 +screenshot "${OUT}/07-password-after-connect.png" +fill "id=\"pair-link-password-input\"" "${PASSWORD}" +wait stable +press "id=\"pair-link-submit\"" +wait stable +wait text "Open a folder on your machine" 30000 +screenshot "${OUT}/08-connected.png" diff --git a/packages/app/e2e/mobile/modal-sheet/stacked-settings.ad b/packages/app/e2e/mobile/modal-sheet/stacked-settings.ad new file mode 100644 index 0000000000..da687d7e02 --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/stacked-settings.ad @@ -0,0 +1,46 @@ +# Settings with Add host visible: no saved hosts, or the host picker already open. +context platform=ios timeout=180000 retries=0 +env APP_ID=sh.paseo.debug +open "${APP_ID}" +wait "id=\"settings-add-host\"" 20000 +screenshot "${OUT}/09-settings.png" +press "id=\"settings-add-host\"" +wait stable +wait "id=\"add-host-method-direct\"" 10000 +screenshot "${OUT}/10-add-host-methods.png" +press "id=\"add-host-method-direct\"" +wait stable +press "id=\"direct-host-advanced-toggle\"" +wait stable +fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}" +wait stable +screenshot "${OUT}/11-add-host-relay-input.png" +press "id=\"direct-host-submit\"" +wait stable +wait "id=\"host-confirmation-connect\"" 10000 +screenshot "${OUT}/12-add-host-confirmation-on-top.png" +press "id=\"host-confirmation-cancel\"" +wait stable +wait "id=\"direct-host-submit\" enabled=true" 10000 +screenshot "${OUT}/13-cancel-returns-to-add-host.png" +# Editing the underlying sheet proves it still accepts input after closing the top sheet. +fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}" +wait stable +press "id=\"direct-host-submit\"" +wait stable +wait "id=\"host-confirmation-connect\"" 10000 +screenshot "${OUT}/14-add-host-confirmation-reopens.png" +press "id=\"host-confirmation-connect\"" +wait stable +wait "id=\"direct-password-input\"" 20000 +screenshot "${OUT}/15-add-host-password.png" +fill "id=\"direct-password-input\"" "${PASSWORD}" +wait stable +press "id=\"direct-host-submit\"" +wait stable +open "${APP_ID}" "paseo://settings" +wait "label=\"Connections\"" 10000 +press "label=\"Connections\"" +wait stable +wait "id=\"host-page-connections-card\"" 30000 +screenshot "${OUT}/16-settings-connected.png" diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 2205409db1..78cdfd2e7a 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { Modal, Platform, Pressable, Text, View } from "react-native"; +import { Keyboard, Pressable, Text, View } from "react-native"; import type { DimensionValue, StyleProp, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -19,7 +19,6 @@ import { type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ArrowLeft, Search, X } from "lucide-react-native"; import { IsolatedBottomSheetModal, @@ -75,11 +74,15 @@ export interface SheetHeader { const SCROLL_CONTENT_GROW = { flexGrow: 1 }; const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; +const NATIVE_DIALOG_SNAP_POINTS = ["100%"]; const styles = StyleSheet.create((theme) => ({ - nativeModalRoot: { + nativeDialogSurface: { flex: 1, }, + nativeDialogBackground: { + backgroundColor: "transparent", + }, desktopOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0,0,0,0.55)", @@ -471,7 +474,7 @@ export interface AdaptiveModalSheetProps { contentStyle?: StyleProp; /** Size compact sheet content to the live snap height instead of its largest snap point. */ sizeContentToCurrentSnapPoint?: boolean; - /** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */ + /** Re-establishes caller-owned contexts inside the native or compact sheet portal. */ contextBridge?: ContextBridge | null; } @@ -526,26 +529,26 @@ export function AdaptiveModalSheet({ () => ({ backgroundColor: theme.colors.palette.zinc[600] }), [theme.colors.palette.zinc], ); + useEffect(() => { + if (!isWeb && visible) { + // A newly opened sheet owns input. A keyboard belonging to the sheet below + // would cover controls in the new sheet, which has no focused input yet. + Keyboard.dismiss(); + } + }, [visible]); + const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible, - isEnabled: isMobile, + isEnabled: isMobile || !isWeb, onClose, }); const [shouldRenderWeb, setShouldRenderWeb] = useState(visible); const [isWebClosing, setIsWebClosing] = useState(false); const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb); - const nativeModalDismissNotifiedRef = useRef(!visible); const handleDismiss = useCallback(() => { handleSheetDismiss(); onDismiss?.(); }, [handleSheetDismiss, onDismiss]); - const notifyNativeModalDismiss = useCallback(() => { - if (nativeModalDismissNotifiedRef.current) { - return; - } - nativeModalDismissNotifiedRef.current = true; - onDismiss?.(); - }, [onDismiss]); const renderBackdrop = useCallback( (props: React.ComponentProps) => ( @@ -592,12 +595,6 @@ export function AdaptiveModalSheet({ onKeyDown: handleWebOverlayKeyDown, }); - useEffect(() => { - if (visible) { - nativeModalDismissNotifiedRef.current = false; - } - }, [visible]); - useEffect(() => { if (!isWeb || isMobile) return; if (visible) { @@ -615,12 +612,6 @@ export function AdaptiveModalSheet({ return () => window.clearTimeout(timeout); }, [visible, isMobile, onDismiss, shouldRenderWeb]); - useEffect(() => { - if (isWeb || isMobile || visible || Platform.OS !== "android") return; - const timeout = setTimeout(notifyNativeModalDismiss, 0); - return () => clearTimeout(timeout); - }, [visible, isMobile, notifyNativeModalDismiss]); - if (isMobile) { const sheetContent = ( <> @@ -725,18 +716,28 @@ export function AdaptiveModalSheet({ } return ( - - {/* Android Modal opens a separate window outside the app's gesture root. */} - - {desktopContent} - - + {desktopContent} + ); }