From 6933e2850be69d777e72bcc8e455c3f94b0918a9 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 26 Jun 2026 16:09:58 -0300 Subject: [PATCH 1/3] fix: render list separators consistently by snapping row heights to the pixel grid Some lists intermittently failed to render the 1px List.Separator, and some rendered it with non-uniform thickness (RoomsListView, MessageActions action sheet, and others). Root cause: rows whose rendered height in DP did not land on the device physical-pixel grid caused the hairline separator to anti-alias below visibility on some rows and across multiple physical pixels on others. As cumulative row offsets drifted fractionally, each successive separator landed at a different sub-pixel phase. Fix: snap each row container height to the pixel grid with PixelRatio.roundToNearestPixel(height * fontScale) so every separator sits at an integer physical-pixel offset and renders as a crisp uniform hairline. The snap is a no-op where the height was already integer-px and corrective otherwise. Applied to every shared list-row component (RoomItem via the useResponsiveLayout rowHeight SSOT, ActionSheet Item via a new useActionSheetItemHeight hook, List.Item, DirectoryItem, UserItem, ServerItem, ServersHistoryItem, DepartmentItemFilter). Also fixes a secondary bug class: incorrect getItemLayout scroll-math in SelectServerView, QueueListView, TeamChannelsView and ShareListView (two used the array length as the per-row pixel height; offsets ignored pixel-snapping and condensed display mode). Ref: NATIVE-1344 Claude-Session: https://claude.ai/code/session_01CrrW51BkNx2qDu1PZWgK5y --- app/containers/ActionSheet/ActionSheet.tsx | 5 +- .../ActionSheet/BottomSheetContent.tsx | 6 +- app/containers/ActionSheet/Item.tsx | 6 +- .../ActionSheet/useActionSheetDetents.ts | 8 +- .../ActionSheet/useActionSheetItemHeight.ts | 8 + app/containers/DirectoryItem/index.tsx | 4 +- app/containers/List/ListItem.tsx | 7 +- .../__snapshots__/ServerItem.test.tsx.snap | 128 +++++++++----- app/containers/ServerItem/index.tsx | 7 +- app/containers/ServerItem/styles.ts | 2 +- app/containers/UserItem.tsx | 12 +- app/ee/omnichannel/views/QueueListView.tsx | 12 +- .../useResponsiveLayout.tsx | 6 +- .../DepartmentFilter/DepartmentItemFilter.tsx | 8 +- .../ServersHistoryItem.test.tsx.snap | 160 +++++++++++------- .../components/ServersHistoryItem/index.tsx | 7 +- .../components/ServersHistoryItem/styles.ts | 5 +- app/views/SelectServerView.tsx | 17 +- app/views/ShareListView/index.tsx | 7 +- app/views/TeamChannelsView.tsx | 24 +-- 20 files changed, 273 insertions(+), 166 deletions(-) create mode 100644 app/containers/ActionSheet/useActionSheetItemHeight.ts diff --git a/app/containers/ActionSheet/ActionSheet.tsx b/app/containers/ActionSheet/ActionSheet.tsx index 37138905170..52377a608aa 100644 --- a/app/containers/ActionSheet/ActionSheet.tsx +++ b/app/containers/ActionSheet/ActionSheet.tsx @@ -19,6 +19,7 @@ import { Handle } from './Handle'; import { type TActionSheetOptions } from './Provider'; import BottomSheetContent from './BottomSheetContent'; import { HANDLE_HEIGHT, useActionSheetDetents } from './useActionSheetDetents'; +import { useActionSheetItemHeight } from './useActionSheetItemHeight'; import styles from './styles'; export const ACTION_SHEET_ANIMATION_DURATION = 250; @@ -26,7 +27,7 @@ export const ACTION_SHEET_ANIMATION_DURATION = 250; const ActionSheet = memo( forwardRef(({ children }: { children: ReactElement }, ref) => { const { colors } = useTheme(); - const { height: windowHeight, width: windowWidth, fontScale } = useWindowDimensions(); + const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const sheetRef = useRef(null); const handleRef = useRef(null); const [data, setData] = useState({} as TActionSheetOptions); @@ -38,7 +39,7 @@ const ActionSheet = memo( // 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 itemHeight = 48 * fontScale; + const itemHeight = useActionSheetItemHeight(); const handleContentLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { setContentHeight(layout.height); diff --git a/app/containers/ActionSheet/BottomSheetContent.tsx b/app/containers/ActionSheet/BottomSheetContent.tsx index 3d9164bca87..8451db9cfda 100644 --- a/app/containers/ActionSheet/BottomSheetContent.tsx +++ b/app/containers/ActionSheet/BottomSheetContent.tsx @@ -1,4 +1,4 @@ -import { FlatList, Text, useWindowDimensions, View, type ViewProps } from 'react-native'; +import { FlatList, Text, View, type ViewProps } from 'react-native'; import { memo, type ReactElement } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -10,6 +10,7 @@ import { type TActionSheetOptionsItem } from './Provider'; import styles from './styles'; import * as List from '../List'; import Touch from '../Touch'; +import { useActionSheetItemHeight } from './useActionSheetItemHeight'; interface IBottomSheetContentProps { hasCancel?: boolean; @@ -39,8 +40,7 @@ const BottomSheetContent = memo( const { colors } = useTheme(); const { bottom } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); - const height = 48 * fontScale; + const height = useActionSheetItemHeight(); const paddingBottom = isAndroid ? bottom + height : bottom; const minHeightStyle = isAndroid || !contentMinHeight ? undefined : { minHeight: contentMinHeight }; diff --git a/app/containers/ActionSheet/Item.tsx b/app/containers/ActionSheet/Item.tsx index 4cbad45586b..4b22a6cb0b6 100644 --- a/app/containers/ActionSheet/Item.tsx +++ b/app/containers/ActionSheet/Item.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { Text, useWindowDimensions, View } from 'react-native'; +import { Text, View } from 'react-native'; import { CustomIcon } from '../CustomIcon'; import { useTheme } from '../../theme'; @@ -9,6 +9,7 @@ import { type TActionSheetOptionsItem } from './Provider'; import styles from './styles'; import { LISTENER } from '../Toast'; import Touch from '../Touch'; +import { useActionSheetItemHeight } from './useActionSheetItemHeight'; export interface IActionSheetItem { item: TActionSheetOptionsItem; @@ -20,7 +21,7 @@ export const Item = memo(({ item, hide }: IActionSheetItem) => { const enabled = item?.enabled ?? true; const { colors } = useTheme(); - const { fontScale } = useWindowDimensions(); + const height = useActionSheetItemHeight(); const onPress = () => { if (enabled) { hide(); @@ -37,7 +38,6 @@ export const Item = memo(({ item, hide }: IActionSheetItem) => { if (!enabled) { color = colors.fontDisabled; } - const height = 48 * fontScale; const accessibilityLabel = item?.accessibilityLabel || (item?.subtitle ? `${item.title}. ${item.subtitle}` : item.title); return ( diff --git a/app/containers/ActionSheet/useActionSheetDetents.ts b/app/containers/ActionSheet/useActionSheetDetents.ts index e5c9eb543a3..64c7ab6a777 100644 --- a/app/containers/ActionSheet/useActionSheetDetents.ts +++ b/app/containers/ActionSheet/useActionSheetDetents.ts @@ -1,6 +1,7 @@ import type { SheetDetent } from '@lodev09/react-native-true-sheet'; import { useMemo } from 'react'; -import { useWindowDimensions } from 'react-native'; + +import { useActionSheetItemHeight } from './useActionSheetItemHeight'; const ACTION_SHEET_MIN_HEIGHT_FRACTION = 0.15; const ACTION_SHEET_MAX_HEIGHT_FRACTION = 0.75; @@ -47,8 +48,7 @@ export function useActionSheetDetents({ hasCancel = false, contentHeight }: UseActionSheetDetentsParams): { detents: SheetDetent[]; maxHeight: number; scrollEnabled: boolean } { - const { fontScale } = useWindowDimensions(); - const CANCEL_HEIGHT = 48 * fontScale; + const CANCEL_HEIGHT = useActionSheetItemHeight(); return useMemo(() => { const maxHeight = windowHeight * ACTION_SHEET_MAX_HEIGHT_FRACTION; @@ -90,5 +90,5 @@ export function useActionSheetDetents({ } return { detents, maxHeight, scrollEnabled }; - }, [bottomInset, contentHeight, hasCancel, headerHeight, itemHeight, optionsLength, snaps, windowHeight]); + }, [bottomInset, CANCEL_HEIGHT, contentHeight, hasCancel, headerHeight, itemHeight, optionsLength, snaps, windowHeight]); } diff --git a/app/containers/ActionSheet/useActionSheetItemHeight.ts b/app/containers/ActionSheet/useActionSheetItemHeight.ts new file mode 100644 index 00000000000..919fe6916dc --- /dev/null +++ b/app/containers/ActionSheet/useActionSheetItemHeight.ts @@ -0,0 +1,8 @@ +import { PixelRatio, useWindowDimensions } from 'react-native'; + +export const ACTION_SHEET_ITEM_HEIGHT = 48; + +export const useActionSheetItemHeight = () => { + const { fontScale } = useWindowDimensions(); + return PixelRatio.roundToNearestPixel(ACTION_SHEET_ITEM_HEIGHT * fontScale); +}; diff --git a/app/containers/DirectoryItem/index.tsx b/app/containers/DirectoryItem/index.tsx index ac58b9d20ef..d5e276b6127 100644 --- a/app/containers/DirectoryItem/index.tsx +++ b/app/containers/DirectoryItem/index.tsx @@ -1,5 +1,5 @@ import { memo, type ReactElement } from 'react'; -import { Text, View, type ViewStyle } from 'react-native'; +import { PixelRatio, Text, View, type ViewStyle } from 'react-native'; import Touch from '../Touch'; import Avatar from '../Avatar'; @@ -49,7 +49,7 @@ const DirectoryItem = ({ }: IDirectoryItem): ReactElement => { const { colors } = useTheme(); const { fontScale } = useResponsiveLayout(); - const height = ROW_HEIGHT * fontScale; + const height = PixelRatio.roundToNearestPixel(ROW_HEIGHT * fontScale); return ( diff --git a/app/containers/List/ListItem.tsx b/app/containers/List/ListItem.tsx index 225704f9cf5..3ffd7f56482 100644 --- a/app/containers/List/ListItem.tsx +++ b/app/containers/List/ListItem.tsx @@ -1,6 +1,7 @@ import { useMemo, memo, type ReactElement } from 'react'; import { I18nManager, + PixelRatio, type StyleProp, StyleSheet, Text, @@ -172,7 +173,11 @@ const Content = memo( return ( { const { colors } = useTheme(); - const { width } = useResponsiveLayout(); + const { width, fontScale } = useResponsiveLayout(); + const height = PixelRatio.roundToNearestPixel(ROW_HEIGHT * fontScale); const iconName = hasCheck ? 'radio-checked' : 'radio-unchecked'; const iconColor = hasCheck ? colors.badgeBackgroundLevel2 : colors.strokeMedium; @@ -48,7 +49,7 @@ const ServerItem = memo(({ item, onPress, onDeletePress, hasCheck }: IServerItem onDeletePress={onDeletePress} testID={`server-item-${item.id}`} width={width}> - + {item.iconURL ? ( { const { colors } = useTheme(); + const { fontScale } = useResponsiveLayout(); + const height = PixelRatio.roundToNearestPixel(ROW_HEIGHT * fontScale); let label = `${name}`; if (icon) { label = `${name} ${isChecked ? i18n.t('Selected') : i18n.t('Unselected')}`; @@ -65,7 +67,7 @@ const UserItem = ({ name, username, onPress, testID, onLongPress, style, icon, i })} accessibilityLabel={label} accessibilityRole='button'> - + diff --git a/app/ee/omnichannel/views/QueueListView.tsx b/app/ee/omnichannel/views/QueueListView.tsx index a558918ec98..2342bb6092b 100644 --- a/app/ee/omnichannel/views/QueueListView.tsx +++ b/app/ee/omnichannel/views/QueueListView.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, memo } from 'react'; import { type CompositeNavigationProp, useNavigation } from '@react-navigation/native'; import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { FlatList, type ListRenderItem, useWindowDimensions } from 'react-native'; +import { FlatList, type ListRenderItem } from 'react-native'; import { shallowEqual, useSelector } from 'react-redux'; import I18n from '../../../i18n'; @@ -20,6 +20,7 @@ import { type MasterDetailInsideStackParamList } from '../../../stacks/MasterDet import { getRoomAvatar, getRoomTitle, getUidDirectMessage, isIOS, isTablet } from '../../../lib/methods/helpers'; import { useResponsiveLayout } from '../../../lib/hooks/useResponsiveLayout/useResponsiveLayout'; import { useMasterDetail } from '../../../lib/hooks/useMasterDetail'; +import { DisplayMode } from '../../../lib/constants/constantDisplayMode'; type TNavigation = CompositeNavigationProp< NativeStackNavigationProp, @@ -34,8 +35,7 @@ const QueueListView = memo(() => { const navigation = useNavigation(); const getScrollRef = useRef>(null); const { colors } = useTheme(); - const { width } = useResponsiveLayout(); - const { fontScale } = useWindowDimensions(); + const { width, rowHeight, rowHeightCondensed } = useResponsiveLayout(); const { username } = useSelector( (state: IApplicationState) => ({ @@ -69,10 +69,10 @@ const QueueListView = memo(() => { }, [isMasterDetail, navigation]); const getItemLayout = (_data: ArrayLike | null | undefined, index: number) => { - const rowHeight = 75 * fontScale; + const height = displayMode === DisplayMode.Condensed ? rowHeightCondensed : rowHeight; return { - length: rowHeight, - offset: rowHeight * index, + length: height, + offset: height * index, index }; }; diff --git a/app/lib/hooks/useResponsiveLayout/useResponsiveLayout.tsx b/app/lib/hooks/useResponsiveLayout/useResponsiveLayout.tsx index 7a5ac7a6f93..e299fd47649 100644 --- a/app/lib/hooks/useResponsiveLayout/useResponsiveLayout.tsx +++ b/app/lib/hooks/useResponsiveLayout/useResponsiveLayout.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, type ReactNode } from 'react'; -import { useWindowDimensions } from 'react-native'; +import { PixelRatio, useWindowDimensions } from 'react-native'; interface IResponsiveLayoutContextData { fontScale: number; @@ -27,8 +27,8 @@ const ResponsiveLayoutProvider = ({ children }: IResponsiveFontScaleProviderProp const isLargeFontScale = fontScale > FONT_SCALE_LIMIT; // `fontScaleLimited` applies the `FONT_SCALE_LIMIT` to prevent layout issues on large font sizes. const fontScaleLimited = isLargeFontScale ? FONT_SCALE_LIMIT : fontScale; - const rowHeight = BASE_ROW_HEIGHT * fontScale; - const rowHeightCondensed = BASE_ROW_HEIGHT_CONDENSED * fontScale; + const rowHeight = PixelRatio.roundToNearestPixel(BASE_ROW_HEIGHT * fontScale); + const rowHeightCondensed = PixelRatio.roundToNearestPixel(BASE_ROW_HEIGHT_CONDENSED * fontScale); return ( { const { colors } = useTheme(); + const { fontScale } = useResponsiveLayout(); + const height = PixelRatio.roundToNearestPixel(ROW_HEIGHT * fontScale); const iconName = currentDepartment?._id === value?._id ? 'check' : null; return ( onPress(value)} style={{ backgroundColor: colors.surfaceRoom }}> - + {value?.name} {iconName ? : null} diff --git a/app/views/NewServerView/components/ServersHistoryItem/__snapshots__/ServersHistoryItem.test.tsx.snap b/app/views/NewServerView/components/ServersHistoryItem/__snapshots__/ServersHistoryItem.test.tsx.snap index 8392ccd733d..6093d21cbdf 100644 --- a/app/views/NewServerView/components/ServersHistoryItem/__snapshots__/ServersHistoryItem.test.tsx.snap +++ b/app/views/NewServerView/components/ServersHistoryItem/__snapshots__/ServersHistoryItem.test.tsx.snap @@ -16,7 +16,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` "right": 0, }, { - "height": 68, + "height": 80, }, { "backgroundColor": "#EC0D2A", @@ -37,7 +37,7 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` "width": 350, }, { - "height": 68, + "height": 80, }, [Function], ] @@ -218,12 +218,16 @@ exports[`Story Snapshots: Content should match snapshot 1`] = ` > { const { colors } = useTheme(); - const { width } = useResponsiveLayout(); + const { width, fontScale } = useResponsiveLayout(); + const height = PixelRatio.roundToNearestPixel(ROW_HEIGHT * fontScale); const accessibilityLabel = item.username ? `${item.url}, ${item.username}` : item.url; const accessibilityHint = I18n.t('Activate_to_select_server_Available_actions_delete'); @@ -34,7 +35,7 @@ const ServersHistoryItem = memo(({ item, onPress, onDeletePress }: IServersHisto width={width} accessibilityLabel={accessibilityLabel} accessibilityHint={accessibilityHint}> - + diff --git a/app/views/NewServerView/components/ServersHistoryItem/styles.ts b/app/views/NewServerView/components/ServersHistoryItem/styles.ts index dedbea25fdc..39de2e1c613 100644 --- a/app/views/NewServerView/components/ServersHistoryItem/styles.ts +++ b/app/views/NewServerView/components/ServersHistoryItem/styles.ts @@ -2,14 +2,13 @@ import { StyleSheet } from 'react-native'; import sharedStyles from '../../../Styles'; -export const ROW_HEIGHT = 56; +export const ROW_HEIGHT = 68; export default StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', - padding: 12, - minHeight: ROW_HEIGHT + padding: 12 }, serverIcon: { width: 44, diff --git a/app/views/SelectServerView.tsx b/app/views/SelectServerView.tsx index f296f88fe02..2e1225b4d39 100644 --- a/app/views/SelectServerView.tsx +++ b/app/views/SelectServerView.tsx @@ -1,5 +1,5 @@ -import { useEffect, useLayoutEffect, useState } from 'react'; -import { FlatList } from 'react-native'; +import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { FlatList, PixelRatio } from 'react-native'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Q } from '@nozbe/watermelondb'; import { useNavigation } from '@react-navigation/native'; @@ -14,8 +14,8 @@ import { type ShareInsideStackParamList } from '../definitions/navigationTypes'; import { type TServerModel } from '../definitions'; import { useAppSelector } from '../lib/hooks/useAppSelector'; import { selectServerRequest } from '../actions/server'; +import { useResponsiveLayout } from '../lib/hooks/useResponsiveLayout/useResponsiveLayout'; -const getItemLayout = (_data: any, index: number) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index }); const keyExtractor = (item: TServerModel) => item.id; const SelectServerView = () => { @@ -24,6 +24,15 @@ const SelectServerView = () => { const server = useAppSelector(state => state.server.server); const navigation = useNavigation>(); + const { fontScale } = useResponsiveLayout(); + + const getItemLayout = useCallback( + (_data: any, index: number) => { + const height = PixelRatio.roundToNearestPixel(ROW_HEIGHT * fontScale); + return { length: height, offset: height * index, index }; + }, + [fontScale] + ); useLayoutEffect(() => { navigation.setOptions({ @@ -56,7 +65,7 @@ const SelectServerView = () => { select(item.id, item.version)} item={item} hasCheck={item.id === server} /> )} keyExtractor={keyExtractor} - getItemLayout={getItemLayout} // Refactor row_height + getItemLayout={getItemLayout} ItemSeparatorComponent={List.Separator} contentContainerStyle={List.styles.contentContainerStyleFlatList} ListHeaderComponent={List.Separator} diff --git a/app/views/ShareListView/index.tsx b/app/views/ShareListView/index.tsx index f4c476b2da0..6770032e410 100644 --- a/app/views/ShareListView/index.tsx +++ b/app/views/ShareListView/index.tsx @@ -1,6 +1,6 @@ import { type Dispatch } from 'redux'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { BackHandler, FlatList, Keyboard, type NativeEventSubscription, Text, View } from 'react-native'; +import { BackHandler, FlatList, Keyboard, type NativeEventSubscription, PixelRatio, Text, View } from 'react-native'; import * as FileSystem from 'expo-file-system/legacy'; import { connect } from 'react-redux'; import * as mime from 'react-native-mime-types'; @@ -63,7 +63,10 @@ interface IShareListViewProps extends INavigationOption { dispatch: Dispatch; } -const getItemLayout = (data: any, index: number) => ({ length: data.length, offset: ROW_HEIGHT * index, index }); +const getItemLayout = (_data: any, index: number) => { + const rowHeight = PixelRatio.roundToNearestPixel(ROW_HEIGHT * PixelRatio.getFontScale()); + return { length: rowHeight, offset: rowHeight * index, index }; +}; const keyExtractor = (item: TSubscriptionModel) => item.rid; class ShareListView extends Component { diff --git a/app/views/TeamChannelsView.tsx b/app/views/TeamChannelsView.tsx index 822c9ab049f..a74ea7b3c15 100644 --- a/app/views/TeamChannelsView.tsx +++ b/app/views/TeamChannelsView.tsx @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { Component } from 'react'; import { deleteRoom } from '../actions/room'; -import { type DisplayMode } from '../lib/constants/constantDisplayMode'; +import { DisplayMode } from '../lib/constants/constantDisplayMode'; import { textInputDebounceTime } from '../lib/constants/debounceConfig'; import { themes } from '../lib/constants/colors'; import { type TActionSheetOptions, type TActionSheetOptionsItem, withActionSheet } from '../containers/ActionSheet'; @@ -19,6 +19,7 @@ import { type IApplicationState, type IBaseScreen, type TSubscriptionModel } fro import { ERoomType } from '../definitions/ERoomType'; import { withDimensions } from '../lib/hooks/withDimensions'; import { withMasterDetail } from '../lib/hooks/useMasterDetail'; +import { BASE_ROW_HEIGHT, BASE_ROW_HEIGHT_CONDENSED } from '../lib/hooks/useResponsiveLayout/useResponsiveLayout'; import I18n from '../i18n'; import database from '../lib/database'; import { CustomIcon } from '../containers/CustomIcon'; @@ -33,14 +34,6 @@ import { getRoomInfo, getTeamListRoom, updateTeamRoom, removeTeamRoom } from '.. const API_FETCH_COUNT = 25; -const getItemLayout = (data: ArrayLike | null | undefined, index: number) => { - const rowHeight = 75 * PixelRatio.getFontScale(); - return { - length: data?.length || 0, - offset: rowHeight * index, - index - }; -}; const keyExtractor = (item: IItem) => item._id; export interface IItem { @@ -515,6 +508,17 @@ class TeamChannelsView extends Component | null | undefined, index: number) => { + const { displayMode } = this.props; + const base = displayMode === DisplayMode.Condensed ? BASE_ROW_HEIGHT_CONDENSED : BASE_ROW_HEIGHT; + const rowHeight = PixelRatio.roundToNearestPixel(base * PixelRatio.getFontScale()); + return { + length: rowHeight, + offset: rowHeight * index, + index + }; + }; + renderItem = ({ item }: { item: IItem }) => { const { StoreLastMessage, useRealName, width, showAvatar, displayMode } = this.props; return ( @@ -561,7 +565,7 @@ class TeamChannelsView extends Component this.load()} From 942291622214466074c7f95d3eb89b0b58a798d6 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 26 Jun 2026 16:39:15 -0300 Subject: [PATCH 2/3] fix: include separator height in list getItemLayout offsets Address review feedback: - SelectServerView and ShareListView getItemLayout now add the row separator height to each offset, so the virtualization math matches the actual rendered positions of separated rows. - useActionSheetDetents reuses the itemHeight it already receives instead of calling useActionSheetItemHeight again, which always returned the same value. - Add an explicit return type to useActionSheetItemHeight. Claude-Session: https://claude.ai/code/session_01PbWnUn8q3EUbGkNWyX33c9 --- app/containers/ActionSheet/useActionSheetDetents.ts | 10 +++------- app/containers/ActionSheet/useActionSheetItemHeight.ts | 2 +- app/views/SelectServerView.tsx | 4 ++-- app/views/ShareListView/index.tsx | 4 ++-- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/containers/ActionSheet/useActionSheetDetents.ts b/app/containers/ActionSheet/useActionSheetDetents.ts index 64c7ab6a777..481919f57a7 100644 --- a/app/containers/ActionSheet/useActionSheetDetents.ts +++ b/app/containers/ActionSheet/useActionSheetDetents.ts @@ -1,8 +1,6 @@ import type { SheetDetent } from '@lodev09/react-native-true-sheet'; import { useMemo } from 'react'; -import { useActionSheetItemHeight } from './useActionSheetItemHeight'; - const ACTION_SHEET_MIN_HEIGHT_FRACTION = 0.15; const ACTION_SHEET_MAX_HEIGHT_FRACTION = 0.75; const SCROLL_ENABLED_THRESHOLD = 0.6; @@ -48,15 +46,13 @@ export function useActionSheetDetents({ hasCancel = false, contentHeight }: UseActionSheetDetentsParams): { detents: SheetDetent[]; maxHeight: number; scrollEnabled: boolean } { - const CANCEL_HEIGHT = useActionSheetItemHeight(); - return useMemo(() => { const maxHeight = windowHeight * ACTION_SHEET_MAX_HEIGHT_FRACTION; const hasOptions = optionsLength > 0; const maxSnap = hasOptions ? Math.min( - (itemHeight + 0.5) * optionsLength + HANDLE_HEIGHT + headerHeight + bottomInset + (hasCancel ? CANCEL_HEIGHT : 0), + (itemHeight + 0.5) * optionsLength + HANDLE_HEIGHT + headerHeight + bottomInset + (hasCancel ? itemHeight : 0), maxHeight ) : 0; @@ -72,7 +68,7 @@ export function useActionSheetDetents({ scrollEnabled = true; } else { const measuredHeight = - optionsLength * itemHeight + HANDLE_HEIGHT + headerHeight + bottomInset + (hasCancel ? CANCEL_HEIGHT : 0); + optionsLength * itemHeight + HANDLE_HEIGHT + headerHeight + bottomInset + (hasCancel ? itemHeight : 0); scrollEnabled = false; detents = [heightToDetent(Math.round(measuredHeight), windowHeight)]; @@ -90,5 +86,5 @@ export function useActionSheetDetents({ } return { detents, maxHeight, scrollEnabled }; - }, [bottomInset, CANCEL_HEIGHT, contentHeight, hasCancel, headerHeight, itemHeight, optionsLength, snaps, windowHeight]); + }, [bottomInset, contentHeight, hasCancel, headerHeight, itemHeight, optionsLength, snaps, windowHeight]); } diff --git a/app/containers/ActionSheet/useActionSheetItemHeight.ts b/app/containers/ActionSheet/useActionSheetItemHeight.ts index 919fe6916dc..d9319012774 100644 --- a/app/containers/ActionSheet/useActionSheetItemHeight.ts +++ b/app/containers/ActionSheet/useActionSheetItemHeight.ts @@ -2,7 +2,7 @@ import { PixelRatio, useWindowDimensions } from 'react-native'; export const ACTION_SHEET_ITEM_HEIGHT = 48; -export const useActionSheetItemHeight = () => { +export const useActionSheetItemHeight = (): number => { const { fontScale } = useWindowDimensions(); return PixelRatio.roundToNearestPixel(ACTION_SHEET_ITEM_HEIGHT * fontScale); }; diff --git a/app/views/SelectServerView.tsx b/app/views/SelectServerView.tsx index 2e1225b4d39..ade37319044 100644 --- a/app/views/SelectServerView.tsx +++ b/app/views/SelectServerView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; -import { FlatList, PixelRatio } from 'react-native'; +import { FlatList, PixelRatio, StyleSheet } from 'react-native'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Q } from '@nozbe/watermelondb'; import { useNavigation } from '@react-navigation/native'; @@ -29,7 +29,7 @@ const SelectServerView = () => { const getItemLayout = useCallback( (_data: any, index: number) => { const height = PixelRatio.roundToNearestPixel(ROW_HEIGHT * fontScale); - return { length: height, offset: height * index, index }; + return { length: height, offset: (height + StyleSheet.hairlineWidth) * index, index }; }, [fontScale] ); diff --git a/app/views/ShareListView/index.tsx b/app/views/ShareListView/index.tsx index 6770032e410..e159da28701 100644 --- a/app/views/ShareListView/index.tsx +++ b/app/views/ShareListView/index.tsx @@ -1,6 +1,6 @@ import { type Dispatch } from 'redux'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { BackHandler, FlatList, Keyboard, type NativeEventSubscription, PixelRatio, Text, View } from 'react-native'; +import { BackHandler, FlatList, Keyboard, type NativeEventSubscription, PixelRatio, StyleSheet, Text, View } from 'react-native'; import * as FileSystem from 'expo-file-system/legacy'; import { connect } from 'react-redux'; import * as mime from 'react-native-mime-types'; @@ -65,7 +65,7 @@ interface IShareListViewProps extends INavigationOption { const getItemLayout = (_data: any, index: number) => { const rowHeight = PixelRatio.roundToNearestPixel(ROW_HEIGHT * PixelRatio.getFontScale()); - return { length: rowHeight, offset: rowHeight * index, index }; + return { length: rowHeight, offset: (rowHeight + StyleSheet.hairlineWidth) * index, index }; }; const keyExtractor = (item: TSubscriptionModel) => item.rid; From 463e74e92a1e472459dcb4d055bd3ec92478e1b8 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 30 Jun 2026 18:41:01 -0300 Subject: [PATCH 3/3] fix: pin MessageActions reactions header to a stable height The reactions header rendered without an explicit height, so its height settled from the async layout of the inner horizontal emoji list. TrueSheet's floating native header sized to that settling content, and on the first present the header overlay briefly covered the first inter-row separator (Edit/Quote) until a relayout repainted it. This reproduced only on real iOS devices at @3x, not Android or the simulator, which is why the earlier pixel-grid row-height fix could not address it. Pin the container to HEADER_HEIGHT so the header height is stable from the first frame and never depends on the async emoji-list layout. --- app/containers/MessageActions/Header.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/containers/MessageActions/Header.tsx b/app/containers/MessageActions/Header.tsx index 708a648d0ae..fb5698e4886 100644 --- a/app/containers/MessageActions/Header.tsx +++ b/app/containers/MessageActions/Header.tsx @@ -39,6 +39,8 @@ const ITEM_MARGIN = 8; const styles = StyleSheet.create({ container: { + height: HEADER_HEIGHT, + justifyContent: 'center', alignItems: 'center', marginHorizontal: CONTAINER_MARGIN, paddingBottom: 16