Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3df8fb8
feat: install react-native-owl
OtavioStasiak Jun 9, 2026
855d8c1
fix: blank space on actionSheet
OtavioStasiak Jun 9, 2026
f55ea6c
feat: visual regression test start
OtavioStasiak Jun 9, 2026
28c992d
feat: visual regression run/compare + e2e-style CI flow
OtavioStasiak Jun 11, 2026
8f8d4ff
Merge branch 'develop' into fix/actionsheet-safe-area-spacing
OtavioStasiak Jun 11, 2026
c2deb72
ci: gate visual regression behind manual approval hold
OtavioStasiak Jun 11, 2026
ebab06a
ci: fix visual regression android build + ios simulator boot
OtavioStasiak Jun 11, 2026
f66caab
ci: emit clean baseline-only artifact for owl update runs
OtavioStasiak Jun 12, 2026
3298251
ci: harden owl renders for deterministic baselines
OtavioStasiak Jun 12, 2026
039c25f
ci: mask OS status bar in owl diffs so baselines verify the app
OtavioStasiak Jun 15, 2026
b54b6f7
Merge branch 'develop' into fix/actionsheet-safe-area-spacing
OtavioStasiak Jun 15, 2026
f5d4590
fix: blank space on actionSheet
OtavioStasiak Jun 15, 2026
6e99aa2
test: guard action-sheet bottom safe-area spacing
OtavioStasiak Jun 15, 2026
c1399c5
test: also guard paddingBottom in action-sheet spacing tests
OtavioStasiak Jun 16, 2026
6544e0b
Merge branch 'develop' into fix/actionsheet-safe-area-spacing
OtavioStasiak Jun 16, 2026
d7adf67
fix: ServersList Add Server button clipped in landscape
OtavioStasiak Jun 16, 2026
cbd23fa
test: lock UserNotificationPreferences sheet paddingBottom contract
OtavioStasiak Jun 16, 2026
e6c40e2
fix: listPicker
OtavioStasiak Jun 16, 2026
b63cdf2
fix: tests
OtavioStasiak Jun 16, 2026
bebcbc7
fix: android specific padding issues
OtavioStasiak Jun 18, 2026
1c4c531
fix: action sheet last row clipped behind Android nav bar
OtavioStasiak Jun 19, 2026
df87d03
feat: improve e2e tests
OtavioStasiak Jun 19, 2026
1559d51
Merge branch 'develop' into fix/actionsheet-safe-area-spacing
OtavioStasiak Jun 19, 2026
e77c60f
refactor: dedupe action sheet max-height fraction in servers list
OtavioStasiak Jun 22, 2026
153eac9
refactor: simplify getActionSheetBottomInset to take bottom inset dir…
OtavioStasiak Jun 22, 2026
8481335
fix: use nullish coalescing for action sheet content padding
OtavioStasiak Jun 22, 2026
c879f00
refactor: drop initialWindowMetrics workaround from action sheet bott…
OtavioStasiak Jun 23, 2026
a96ce16
Merge branch 'develop' into fix/actionsheet-safe-area-spacing
OtavioStasiak Jun 23, 2026
6e5ca3f
Merge branch 'develop' into fix/actionsheet-safe-area-spacing
OtavioStasiak Jun 24, 2026
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
127 changes: 127 additions & 0 deletions .maestro/tests/assorted/action-sheet-safe-area.yaml
Original file line number Diff line number Diff line change
@@ -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'
12 changes: 6 additions & 6 deletions app/containers/ActionSheet/ActionSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,20 @@ 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';
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;
Expand All @@ -34,11 +35,9 @@ const ActionSheet = memo(
const [contentHeight, setContentHeight] = useState(0);
const onCloseSnapshotRef = useRef<TActionSheetOptions['onClose']>(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);

const handleContentLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
setContentHeight(layout.height);
Expand Down Expand Up @@ -107,7 +106,7 @@ const ActionSheet = memo(

const { detents, maxHeight, scrollEnabled } = useActionSheetDetents({
windowHeight,
bottomInset: bottom,
bottomInset,
itemHeight,
optionsLength: data?.options?.length || 0,
snaps: effectiveSnaps,
Expand Down Expand Up @@ -156,6 +155,7 @@ const ActionSheet = memo(
fullContainer={data.fullContainer}
hugContent={data.hugContent}
contentMinHeight={isIOS ? contentMinHeight : undefined}
contentPaddingBottom={bottomInset}
scrollEnabled={scrollEnabled}>
{data?.children}
</BottomSheetContent>
Expand Down
6 changes: 3 additions & 3 deletions app/containers/ActionSheet/BottomSheetContent.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,6 +19,7 @@ interface IBottomSheetContentProps {
fullContainer?: boolean;
hugContent?: boolean;
contentMinHeight?: number;
contentPaddingBottom?: number;
scrollEnabled?: boolean;
}

Expand All @@ -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;
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
const minHeightStyle = isAndroid || !contentMinHeight ? undefined : { minHeight: contentMinHeight };

const renderFooter = () =>
Expand Down
16 changes: 16 additions & 0 deletions app/containers/ActionSheet/getActionSheetBottomInset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Platform } from 'react-native';
import { initialWindowMetrics, type EdgeInsets } from 'react-native-safe-area-context';

import { isAndroid, isIOS } from '../../lib/methods/helpers';

export function getActionSheetBottomInset(liveInsets: EdgeInsets): number {
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
const isNewAndroid = isAndroid && Number(Platform.Version) >= 36;
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
if (isIOS || isNewAndroid) {
return 0;
}

const capturedNavBarInset = initialWindowMetrics?.insets.bottom ?? 0;
const navBarInset = Math.max(liveInsets.bottom, capturedNavBarInset);
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated

return navBarInset;
}
4 changes: 1 addition & 3 deletions app/views/DirectoryView/Options.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,7 +23,6 @@ const DirectoryOptions = ({
toggleWorkspace
}: IDirectoryOptionsProps) => {
const { colors } = useTheme();
const insets = useSafeAreaInsets();

const renderItem = (itemType: string) => {
let text = 'Users';
Expand Down Expand Up @@ -52,7 +50,7 @@ const DirectoryOptions = ({
};

return (
<List.Container contentContainerStyle={{ backgroundColor: colors.surfaceRoom, marginBottom: insets.bottom }}>
<List.Container contentContainerStyle={{ backgroundColor: colors.surfaceRoom }}>
<List.Separator />
{renderItem('channels')}
<List.Separator />
Expand Down
4 changes: 1 addition & 3 deletions app/views/MediaAutoDownloadView/ListPicker.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 => (
<View style={{ backgroundColor: colors.surfaceRoom, marginBottom: insets.bottom }}>
<View style={{ backgroundColor: colors.surfaceRoom }}>
<List.Separator />
{OPTIONS.map(i => (
<Fragment key={i.value}>
Expand Down
14 changes: 5 additions & 9 deletions app/views/RoomsListView/components/ServersList.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -24,9 +23,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';
Expand All @@ -37,7 +34,7 @@ const ServersList = () => {
const server = useAppSelector(state => state.server.server);
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { height: windowHeight } = useWindowDimensions();

useLayoutEffect(() => {
const init = () => {
Expand Down Expand Up @@ -124,15 +121,14 @@ const ServersList = () => {
<View
style={{
backgroundColor: colors.surfaceLight,
borderColor: colors.strokeLight,
marginBottom: insets.bottom
borderColor: colors.strokeLight
}}
testID='rooms-list-header-servers-list'>
<View style={[styles.serversListContainerHeader, styles.serverHeader, { borderColor: colors.strokeLight }]}>
<Text style={[styles.serverHeaderText, { color: colors.fontSecondaryInfo }]}>{I18n.t('Workspaces')}</Text>
</View>
<FlatList
style={{ maxHeight: MAX_ROWS * ROW_HEIGHT }}
style={{ maxHeight: getServersListMaxHeight(windowHeight) }}
data={servers}
keyExtractor={item => item.id}
renderItem={renderItem}
Expand Down
27 changes: 27 additions & 0 deletions app/views/RoomsListView/components/serversListLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Layout math for the Workspaces (servers list) action sheet, kept dependency-free
Comment thread
OtavioStasiak marked this conversation as resolved.
// so it can be unit-tested without the redux/database tree the component needs.
Comment thread
OtavioStasiak marked this conversation as resolved.

export const SERVERS_LIST_ROW_HEIGHT = 68;
export const SERVERS_LIST_MAX_ROWS = 4.5;

// Vertical space the sheet needs for everything that is NOT the scrollable server
// list: the "Workspaces" header, the separator, the Add Server button block, and
// the sheet handle. Reserved so the button is never pushed off-screen.
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
const SERVERS_LIST_CHROME_HEIGHT = 150;
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated

// Mirrors ACTION_SHEET_MAX_HEIGHT_FRACTION in useActionSheetDetents: a children
// action sheet can grow to at most this fraction of the window height.
const ACTION_SHEET_MAX_HEIGHT_FRACTION = 0.75;
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated

/**
* 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 => {
Comment thread
OtavioStasiak marked this conversation as resolved.
const fullHeight = SERVERS_LIST_MAX_ROWS * SERVERS_LIST_ROW_HEIGHT;
const availableForList = windowHeight * ACTION_SHEET_MAX_HEIGHT_FRACTION - SERVERS_LIST_CHROME_HEIGHT;
return Math.max(SERVERS_LIST_ROW_HEIGHT, Math.min(fullHeight, availableForList));
};
4 changes: 1 addition & 3 deletions app/views/UserNotificationPreferencesView/ListPicker.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 => (
<View style={{ backgroundColor: colors.surfaceRoom, marginBottom: insets.bottom }}>
<View style={{ backgroundColor: colors.surfaceRoom }}>
Comment thread
OtavioStasiak marked this conversation as resolved.
<List.Separator />
{OPTIONS[preference].map(i => (
<Fragment key={i.value}>
Expand Down
Loading