diff --git a/.maestro/tests/assorted/action-sheet-safe-area.yaml b/.maestro/tests/assorted/action-sheet-safe-area.yaml new file mode 100644 index 0000000000..d6712325bd --- /dev/null +++ b/.maestro/tests/assorted/action-sheet-safe-area.yaml @@ -0,0 +1,127 @@ +appId: ${APP_ID} +name: Action Sheet Safe Area +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +onFlowComplete: + - evalScript: ${output.utils.deleteCreatedUsers()} +tags: + - test-7 + +# Regression for the action-sheet bottom safe-area spacing fix. The sheet +# (TrueSheet / BottomSheetContent) owns the bottom safe-area inset; content must +# not add its own bottom margin, which would either leave a blank band or push +# the bottom-most row off-screen. Maestro can't measure a pixel gap, so the guard +# is that each sheet opens and its bottom-most content stays fully visible. + +--- +- evalScript: ${output.user = output.utils.createUser()} +- evalScript: ${output.actionSheetMessage = 'message-action-sheet'} +- evalScript: ${output.room = output.utils.createRandomRoom(output.user.username, output.user.password)} +- evalScript: ${output.utils.sendMessage(output.user.username, output.user.password, output.room.name, output.actionSheetMessage)} + +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} + +- extendedWaitUntil: + visible: + id: 'rooms-list-view' + timeout: 60000 + +# Message actions action sheet — bottom-most row (Delete) must stay reachable and visible. +- runFlow: + file: '../../helpers/navigate-to-room.yaml' + env: + ROOM: ${output.room.name} +- extendedWaitUntil: + visible: + id: 'message-content-${output.actionSheetMessage}' + timeout: 60000 +- longPressOn: + id: 'message-content-${output.actionSheetMessage}' +- extendedWaitUntil: + visible: + id: 'action-sheet' + timeout: 60000 +- swipe: + from: + id: 'action-sheet-handle' + direction: UP +- waitForAnimationToEnd: + timeout: 10000 +- scrollUntilVisible: + element: + id: 'message-actions-delete' + direction: DOWN + timeout: 20000 +- assertVisible: + id: 'message-actions-delete' + +# Dismiss the sheet and return to the rooms list for the remaining cases. +- swipe: + direction: DOWN + duration: 500 +- waitForAnimationToEnd: + timeout: 10000 +- runFlow: '../../helpers/go-back.yaml' +- extendedWaitUntil: + visible: + id: 'rooms-list-view' + timeout: 60000 + +# Workspaces (servers list) action sheet — the bottom "Add server" row must be visible. +- extendedWaitUntil: + visible: + id: 'rooms-list-header-servers-list-button' + timeout: 60000 +- tapOn: + id: 'rooms-list-header-servers-list-button' +- waitForAnimationToEnd: + timeout: 10000 +- extendedWaitUntil: + visible: + id: 'action-sheet' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'rooms-list-header-servers-list' + timeout: 60000 +- assertVisible: + id: 'rooms-list-header-server-add' + +# Dismiss the sheet before moving on. +- swipe: + direction: DOWN + duration: 500 +- waitForAnimationToEnd: + timeout: 10000 + +# Directory filter action sheet — the bottom radio row must be visible. +- extendedWaitUntil: + visible: + id: 'rooms-list-view-directory' + timeout: 60000 +- tapOn: + id: 'rooms-list-view-directory' +- extendedWaitUntil: + visible: + id: 'directory-view' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'directory-view-filter' + timeout: 60000 +- tapOn: + id: 'directory-view-filter' +- waitForAnimationToEnd: + timeout: 10000 +- extendedWaitUntil: + visible: + id: 'action-sheet' + timeout: 60000 +- assertVisible: + id: 'directory-switch-channels' +- assertVisible: + id: 'directory-switch-teams' diff --git a/app/containers/ActionSheet/ActionSheet.tsx b/app/containers/ActionSheet/ActionSheet.tsx index 3713890517..ba415e78d1 100644 --- a/app/containers/ActionSheet/ActionSheet.tsx +++ b/app/containers/ActionSheet/ActionSheet.tsx @@ -6,12 +6,12 @@ import { findNodeHandle, Keyboard, type LayoutChangeEvent, - Platform, useWindowDimensions, type View } from 'react-native'; import { TrueSheet } from '@lodev09/react-native-true-sheet'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../../theme'; import { isAndroid, isIOS } from '../../lib/methods/helpers'; @@ -19,6 +19,7 @@ import { Handle } from './Handle'; import { type TActionSheetOptions } from './Provider'; import BottomSheetContent from './BottomSheetContent'; import { HANDLE_HEIGHT, useActionSheetDetents } from './useActionSheetDetents'; +import { getActionSheetBottomInset } from './getActionSheetBottomInset'; import styles from './styles'; export const ACTION_SHEET_ANIMATION_DURATION = 250; @@ -34,11 +35,9 @@ const ActionSheet = memo( const [contentHeight, setContentHeight] = useState(0); const onCloseSnapshotRef = useRef(undefined); - // TrueSheet detects the bottom inset for Android 16 and iOS - // To avoid content hiding behind navigation bar on older Android versions - const isNewAndroid = isAndroid && Number(Platform.Version) >= 36; - const bottom = isIOS || isNewAndroid ? 0 : windowHeight * 0.03; + const insets = useSafeAreaInsets(); const itemHeight = 48 * fontScale; + const bottomInset = getActionSheetBottomInset(insets.bottom); const handleContentLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { setContentHeight(layout.height); @@ -107,7 +106,7 @@ const ActionSheet = memo( const { detents, maxHeight, scrollEnabled } = useActionSheetDetents({ windowHeight, - bottomInset: bottom, + bottomInset, itemHeight, optionsLength: data?.options?.length || 0, snaps: effectiveSnaps, @@ -156,6 +155,7 @@ const ActionSheet = memo( fullContainer={data.fullContainer} hugContent={data.hugContent} contentMinHeight={isIOS ? contentMinHeight : undefined} + contentPaddingBottom={bottomInset} scrollEnabled={scrollEnabled}> {data?.children} diff --git a/app/containers/ActionSheet/BottomSheetContent.tsx b/app/containers/ActionSheet/BottomSheetContent.tsx index 3d9164bca8..ad00c2dd28 100644 --- a/app/containers/ActionSheet/BottomSheetContent.tsx +++ b/app/containers/ActionSheet/BottomSheetContent.tsx @@ -1,6 +1,5 @@ import { FlatList, Text, useWindowDimensions, View, type ViewProps } from 'react-native'; import { memo, type ReactElement } from 'react'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import I18n from '../../i18n'; import { useTheme } from '../../theme'; @@ -20,6 +19,7 @@ interface IBottomSheetContentProps { fullContainer?: boolean; hugContent?: boolean; contentMinHeight?: number; + contentPaddingBottom?: number; scrollEnabled?: boolean; } @@ -33,15 +33,15 @@ const BottomSheetContent = memo( fullContainer, hugContent, contentMinHeight, + contentPaddingBottom, scrollEnabled }: IBottomSheetContentProps) => { 'use memo'; const { colors } = useTheme(); - const { bottom } = useSafeAreaInsets(); const { fontScale } = useWindowDimensions(); const height = 48 * fontScale; - const paddingBottom = isAndroid ? bottom + height : bottom; + const paddingBottom = contentPaddingBottom ?? 0; const minHeightStyle = isAndroid || !contentMinHeight ? undefined : { minHeight: contentMinHeight }; const renderFooter = () => diff --git a/app/containers/ActionSheet/getActionSheetBottomInset.ts b/app/containers/ActionSheet/getActionSheetBottomInset.ts new file mode 100644 index 0000000000..6cca420c48 --- /dev/null +++ b/app/containers/ActionSheet/getActionSheetBottomInset.ts @@ -0,0 +1,20 @@ +import { Platform } from 'react-native'; + +import { isAndroid, isIOS } from '../../lib/methods/helpers'; + +// Android API level (SDK_INT) at which edge-to-edge is enforced and TrueSheet +// auto-reserves the navigation-bar inset itself. At or above this level we let +// native handle it and return 0; below it we rely on the live safe-area inset. +const TRUE_SHEET_EDGE_TO_EDGE_SDK_INT = 36; + +export function getActionSheetBottomInset(liveBottom: number): number { + const nativeReservesInset = isAndroid && Number(Platform.Version) >= TRUE_SHEET_EDGE_TO_EDGE_SDK_INT; + if (isIOS || nativeReservesInset) { + return 0; + } + + // On older Android the app is not yet edge-to-edge, so this reads 0 today. + // Proper nav-bar inset handling is deferred to the dedicated edge-to-edge PR, + // after which this live inset becomes correct and the sheet reserves it. + return liveBottom; +} diff --git a/app/containers/ActionSheet/useActionSheetDetents.ts b/app/containers/ActionSheet/useActionSheetDetents.ts index e5c9eb543a..d1eb8e5023 100644 --- a/app/containers/ActionSheet/useActionSheetDetents.ts +++ b/app/containers/ActionSheet/useActionSheetDetents.ts @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useWindowDimensions } from 'react-native'; const ACTION_SHEET_MIN_HEIGHT_FRACTION = 0.15; -const ACTION_SHEET_MAX_HEIGHT_FRACTION = 0.75; +export const ACTION_SHEET_MAX_HEIGHT_FRACTION = 0.75; const SCROLL_ENABLED_THRESHOLD = 0.6; export const HANDLE_HEIGHT = 28; diff --git a/app/views/DirectoryView/Options.tsx b/app/views/DirectoryView/Options.tsx index 9be46a2ee5..2f28a9f8ca 100644 --- a/app/views/DirectoryView/Options.tsx +++ b/app/views/DirectoryView/Options.tsx @@ -1,5 +1,4 @@ import { Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { CustomIcon, type TIconsName } from '../../containers/CustomIcon'; import * as List from '../../containers/List'; @@ -24,7 +23,6 @@ const DirectoryOptions = ({ toggleWorkspace }: IDirectoryOptionsProps) => { const { colors } = useTheme(); - const insets = useSafeAreaInsets(); const renderItem = (itemType: string) => { let text = 'Users'; @@ -52,7 +50,7 @@ const DirectoryOptions = ({ }; return ( - + {renderItem('channels')} diff --git a/app/views/MediaAutoDownloadView/ListPicker.tsx b/app/views/MediaAutoDownloadView/ListPicker.tsx index c4f8d9c17b..139edb158e 100644 --- a/app/views/MediaAutoDownloadView/ListPicker.tsx +++ b/app/views/MediaAutoDownloadView/ListPicker.tsx @@ -1,6 +1,5 @@ import { Fragment, type ReactElement } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useActionSheet } from '../../containers/ActionSheet'; import * as List from '../../containers/List'; @@ -68,11 +67,10 @@ const ListPicker = ({ } & IBaseParams) => { const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); - const insets = useSafeAreaInsets(); const option = OPTIONS.find(option => option.value === value) || OPTIONS[2]; const getOptions = (): ReactElement => ( - + {OPTIONS.map(i => ( diff --git a/app/views/RoomsListView/components/ServersList.tsx b/app/views/RoomsListView/components/ServersList.tsx index 1a15a4489d..7b97050d6e 100644 --- a/app/views/RoomsListView/components/ServersList.tsx +++ b/app/views/RoomsListView/components/ServersList.tsx @@ -1,6 +1,5 @@ import { memo, useLayoutEffect, useRef, useState } from 'react'; -import { FlatList, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { FlatList, Text, useWindowDimensions, View } from 'react-native'; import { batch, useDispatch } from 'react-redux'; import { type Subscription } from 'rxjs'; @@ -25,9 +24,7 @@ import { events, logEvent } from '../../../lib/methods/helpers/log'; import UserPreferences from '../../../lib/methods/userPreferences'; import { useTheme } from '../../../theme'; import styles from '../styles'; - -const ROW_HEIGHT = 68; -const MAX_ROWS = 4.5; +import { getServersListMaxHeight } from './serversListLayout'; const ServersList = () => { 'use memo'; @@ -38,7 +35,7 @@ const ServersList = () => { const server = useAppSelector(state => state.server.server); const isMasterDetail = useMasterDetail(); const { colors } = useTheme(); - const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); useLayoutEffect(() => { const init = () => { @@ -125,15 +122,14 @@ const ServersList = () => { {I18n.t('Workspaces')} item.id} renderItem={renderItem} diff --git a/app/views/RoomsListView/components/serversListLayout.test.ts b/app/views/RoomsListView/components/serversListLayout.test.ts new file mode 100644 index 0000000000..93eb863eaf --- /dev/null +++ b/app/views/RoomsListView/components/serversListLayout.test.ts @@ -0,0 +1,32 @@ +import { getServersListMaxHeight, SERVERS_LIST_MAX_ROWS, SERVERS_LIST_ROW_HEIGHT } from './serversListLayout'; + +const fullHeight = SERVERS_LIST_MAX_ROWS * SERVERS_LIST_ROW_HEIGHT; + +describe('getServersListMaxHeight', () => { + test('caps at the full MAX_ROWS height in a tall portrait window', () => { + // iPhone-ish portrait: plenty of room, behaviour stays unchanged + expect(getServersListMaxHeight(844)).toBe(fullHeight); + }); + + test('returns the full height exactly at the boundary where the list fits', () => { + // windowHeight * 0.75 - 150 === 306 => windowHeight === 608 + expect(getServersListMaxHeight(608)).toBe(fullHeight); + }); + + test('shrinks below the full height in a short window so the button still fits', () => { + // landscape-ish height: 390 * 0.75 - 150 = 142.5 + const result = getServersListMaxHeight(390); + expect(result).toBe(142.5); + expect(result).toBeLessThan(fullHeight); + expect(result).toBeGreaterThan(SERVERS_LIST_ROW_HEIGHT); + }); + + test('never shrinks below a single row height in a very short window', () => { + // 200 * 0.75 - 150 = 0, clamped up to one row + expect(getServersListMaxHeight(200)).toBe(SERVERS_LIST_ROW_HEIGHT); + }); + + test('floors at a single row height for a zero/degenerate window height', () => { + expect(getServersListMaxHeight(0)).toBe(SERVERS_LIST_ROW_HEIGHT); + }); +}); diff --git a/app/views/RoomsListView/components/serversListLayout.ts b/app/views/RoomsListView/components/serversListLayout.ts new file mode 100644 index 0000000000..839385a263 --- /dev/null +++ b/app/views/RoomsListView/components/serversListLayout.ts @@ -0,0 +1,37 @@ +// Layout math for the Workspaces (servers list) action sheet, kept dependency-free +// so it can be unit-tested without the redux/database tree the component needs. + +import { ACTION_SHEET_MAX_HEIGHT_FRACTION, HANDLE_HEIGHT } from '../../../containers/ActionSheet/useActionSheetDetents'; + +export const SERVERS_LIST_ROW_HEIGHT = 68; +export const SERVERS_LIST_MAX_ROWS = 4.5; + +// Vertical space the sheet reserves for everything that is NOT the scrollable +// server list, so the Add Server button is never pushed off-screen. Built up from +// the real pieces (see ServersList.tsx + RoomsListView/styles.ts) rather than a +// bare guess, so that resizing any of them keeps this in sync. Approximate by +// design — glyph line-height isn't pinned in styles — but a unit test guards the +// total. The two hairline List.Separators are rounded into the text allowance. +const WORKSPACES_HEADER_HEIGHT = 41; // styles.serversListContainerHeader.height +const ADD_SERVER_CONTAINER_PADDING = 16 * 2; // styles.addServerButtonContainer.padding (top + bottom) +const ADD_SERVER_BUTTON_PADDING = 14 * 2; // styles.buttonCreateWorkspace.paddingVertical (top + bottom) +const ADD_SERVER_BUTTON_TEXT_HEIGHT = 21; // ~line-height of the 16px button label +const SERVERS_LIST_RESERVED_HEIGHT = + WORKSPACES_HEADER_HEIGHT + + HANDLE_HEIGHT + + ADD_SERVER_CONTAINER_PADDING + + ADD_SERVER_BUTTON_PADDING + + ADD_SERVER_BUTTON_TEXT_HEIGHT; + +/** + * Max height for the servers FlatList. In portrait there is plenty of room, so it + * stays at the full MAX_ROWS cap (unchanged behaviour). In a short window + * (landscape, split-view) it shrinks so the header + Add Server button still fit + * inside the sheet's max height and the list scrolls instead of clipping the + * button — e.g. landscape with 3+ workspaces connected. Never smaller than one row. + */ +export const getServersListMaxHeight = (windowHeight: number): number => { + const fullHeight = SERVERS_LIST_MAX_ROWS * SERVERS_LIST_ROW_HEIGHT; + const availableForList = windowHeight * ACTION_SHEET_MAX_HEIGHT_FRACTION - SERVERS_LIST_RESERVED_HEIGHT; + return Math.max(SERVERS_LIST_ROW_HEIGHT, Math.min(fullHeight, availableForList)); +}; diff --git a/app/views/UserNotificationPreferencesView/ListPicker.tsx b/app/views/UserNotificationPreferencesView/ListPicker.tsx index 815b22666b..ec766ce7a9 100644 --- a/app/views/UserNotificationPreferencesView/ListPicker.tsx +++ b/app/views/UserNotificationPreferencesView/ListPicker.tsx @@ -1,5 +1,4 @@ import { StyleSheet, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Fragment, type ReactElement } from 'react'; import * as List from '../../containers/List'; @@ -37,10 +36,9 @@ const ListPicker = ({ const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); const option = value ? OPTIONS[preference].find(option => option.value === value) : OPTIONS[preference][0]; - const insets = useSafeAreaInsets(); const getOptions = (): ReactElement => ( - + {OPTIONS[preference].map(i => (