diff --git a/.maestro/scripts/data-setup.js b/.maestro/scripts/data-setup.js index 39c239541d..3f42e48f9d 100644 --- a/.maestro/scripts/data-setup.js +++ b/.maestro/scripts/data-setup.js @@ -180,6 +180,21 @@ const post = (endpoint, username, password, body) => { return response; }; +// Creates `count` new users and has each of them react to `messageId` with `emoji`. +// Used to reproduce "who reacted" sheets with many reactors (e.g. the reaction-list +// scroll/clipping regression). Created users are tracked by createUser() for cleanup. +const reactAsNewUsers = (count, messageId, emoji) => { + const reactors = []; + + for (let i = 0; i < count; i++) { + const reactor = createUser(); + post('chat.react', reactor.username, reactor.password, { messageId, emoji, shouldReact: true }); + reactors.push(reactor); + } + + return reactors; +}; + const createDM = (username, password, otherUsername) => { login(username, password); @@ -261,6 +276,7 @@ output.utils = { sendMessage, getProfileInfo, post, + reactAsNewUsers, login, getDeepLink, createDM, diff --git a/.maestro/tests/room/reaction-list-scroll-to-last-user.yaml b/.maestro/tests/room/reaction-list-scroll-to-last-user.yaml new file mode 100644 index 0000000000..e4b242c392 --- /dev/null +++ b/.maestro/tests/room/reaction-list-scroll-to-last-user.yaml @@ -0,0 +1,65 @@ +appId: ${APP_ID} +name: Reaction List Scroll To Last User +jsEngine: graaljs +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +onFlowComplete: + - evalScript: ${output.utils.deleteCreatedUsers()} +tags: + - test-6 + +--- +- evalScript: ${output.user = output.utils.createUser()} +- evalScript: ${output.room = output.utils.createRandomRoom(output.user.username, output.user.password)} +- evalScript: ${output.randomMessage = 'reaction-list-message-' + output.random()} +- evalScript: ${output.message = output.utils.sendMessage(output.user.username, output.user.password, output.room.name, output.randomMessage)} + +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} +- runFlow: + file: '../../helpers/navigate-to-room.yaml' + env: + ROOM: ${output.room.name} + +- extendedWaitUntil: + visible: + text: '.*${output.randomMessage}.*' + timeout: 60000 + +# Create 15 users and have them react to the message with a thumbs-up emoji +- evalScript: ${output.reactors = output.utils.reactAsNewUsers(15, output.message.message._id, ':thumbsup:')} + +- extendedWaitUntil: + visible: + id: 'message-reaction-:thumbsup:' + timeout: 60000 +- longPressOn: + id: 'message-reaction-:thumbsup:' +- extendedWaitUntil: + visible: + id: reactionsList + timeout: 60000 +- tapOn: + id: 'reactions-tab-:thumbsup:' +- waitForAnimationToEnd: + timeout: 1000 + +# Scroll until the last user is visible in the list of users who reacted to the message. +- evalScript: ${output.userFound = 0} +- repeat: + while: + true: ${output.userFound == 0} + commands: + - swipe: + from: + id: 'usersList-:thumbsup:' + direction: up + - runFlow: + when: + visible: + text: ${output.reactors[14].username} + commands: + - evalScript: ${output.userFound = 1} diff --git a/.sniffler/test-map.json b/.sniffler/test-map.json index e29d6cc889..ba4c342c35 100644 --- a/.sniffler/test-map.json +++ b/.sniffler/test-map.json @@ -273,6 +273,10 @@ "test": ".maestro/tests/room/message-markdown-click.yaml", "dependsOn": ["app/views/RoomView/**", "app/containers/markdown/**", "app/sagas/room.js"] }, + { + "test": ".maestro/tests/room/reaction-list-scroll-to-last-user.yaml", + "dependsOn": ["app/containers/ReactionsList/**", "app/containers/ActionSheet/**"] + }, { "test": ".maestro/tests/room/quote-thread-message.yaml", "dependsOn": [ diff --git a/app/containers/ActionSheet/ActionSheet.tsx b/app/containers/ActionSheet/ActionSheet.tsx index ce1a59e404..4392157a16 100644 --- a/app/containers/ActionSheet/ActionSheet.tsx +++ b/app/containers/ActionSheet/ActionSheet.tsx @@ -11,13 +11,14 @@ import { } 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 { HANDLE_HEIGHT, getSheetContentPaddingBottom, useActionSheetDetents } from './useActionSheetDetents'; import { useActionSheetItemHeight } from './useActionSheetItemHeight'; import styles from './styles'; @@ -32,14 +33,11 @@ const ActionSheet = memo( const [data, setData] = useState({} as TActionSheetOptions); const [isVisible, setIsVisible] = useState(false); const [contentHeight, setContentHeight] = useState(0); + const { bottom } = useSafeAreaInsets(); const onCloseSnapshotRef = useRef(undefined); const itemHeight = useActionSheetItemHeight(); - const handleContentLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { - setContentHeight(layout.height); - }; - const hide = () => { if (!isVisible) return; sheetRef.current?.dismiss(); @@ -116,6 +114,17 @@ const ActionSheet = memo( const hasSnaps = !!effectiveSnaps?.length; const disableContentPanning = data?.enableContentPanningGesture === false; const isScrollable = hasOptions || (hasSnaps && !disableContentPanning); + const contentScrollEnabled = hasOptions ? scrollEnabled : isScrollable; + + const handleContentLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { + const padding = getSheetContentPaddingBottom({ + bottom, + fullContainer: data.fullContainer, + hugContent: data.hugContent, + scrollEnabled: contentScrollEnabled + }); + setContentHeight(Math.max(0, layout.height - padding)); + }; const contentMinHeight = data.fullContainer && effectiveSnaps?.length @@ -152,7 +161,7 @@ const ActionSheet = memo( fullContainer={data.fullContainer} hugContent={data.hugContent} contentMinHeight={isIOS ? contentMinHeight : undefined} - scrollEnabled={scrollEnabled}> + scrollEnabled={contentScrollEnabled}> {data?.children} diff --git a/app/containers/ActionSheet/BottomSheetContent.tsx b/app/containers/ActionSheet/BottomSheetContent.tsx index 8047220567..566ccddd86 100644 --- a/app/containers/ActionSheet/BottomSheetContent.tsx +++ b/app/containers/ActionSheet/BottomSheetContent.tsx @@ -11,6 +11,7 @@ import styles from './styles'; import * as List from '../List'; import Touch from '../Touch'; import { useActionSheetItemHeight } from './useActionSheetItemHeight'; +import { getSheetContentPaddingBottom } from './useActionSheetDetents'; interface IBottomSheetContentProps { hasCancel?: boolean; @@ -40,6 +41,7 @@ const BottomSheetContent = memo( const { bottom } = useSafeAreaInsets(); const height = useActionSheetItemHeight(); const minHeightStyle = isAndroid || !contentMinHeight ? undefined : { minHeight: contentMinHeight }; + const paddingBottom = getSheetContentPaddingBottom({ bottom, fullContainer, hugContent, scrollEnabled }); const renderFooter = () => hasCancel ? ( @@ -78,7 +80,7 @@ const BottomSheetContent = memo( return ( {children} diff --git a/app/containers/ActionSheet/useActionSheetDetents.test.tsx b/app/containers/ActionSheet/useActionSheetDetents.test.tsx index 2564b0cbd7..36bc7e5233 100644 --- a/app/containers/ActionSheet/useActionSheetDetents.test.tsx +++ b/app/containers/ActionSheet/useActionSheetDetents.test.tsx @@ -1,6 +1,14 @@ import { renderHook } from '@testing-library/react-native'; -import { HANDLE_HEIGHT, useActionSheetDetents } from './useActionSheetDetents'; +import { HANDLE_HEIGHT, getSheetContentPaddingBottom, useActionSheetDetents } from './useActionSheetDetents'; + +let mockIsAndroid = false; + +jest.mock('../../lib/methods/helpers/deviceInfo', () => ({ + get isAndroid() { + return mockIsAndroid; + } +})); describe('useActionSheetDetents', () => { const windowHeight = 1000; @@ -86,3 +94,47 @@ describe('useActionSheetDetents', () => { expect(result.current.detents).toEqual([0.15]); }); }); + +describe('getSheetContentPaddingBottom', () => { + const bottom = 48; + + beforeEach(() => { + mockIsAndroid = false; + }); + + it('falls back to the minimum padding when the safe-area bottom is 0', () => { + expect(getSheetContentPaddingBottom({ bottom: 0 })).toBe(32); + }); + + it('returns the safe-area bottom when no flags are set', () => { + expect(getSheetContentPaddingBottom({ bottom })).toBe(bottom); + }); + + it('returns the safe-area bottom on iOS even for a full-container sheet', () => { + expect(getSheetContentPaddingBottom({ bottom, fullContainer: true, scrollEnabled: false })).toBe(bottom); + }); + + it('adds the handle height on Android for a non-scrollable full-container sheet', () => { + mockIsAndroid = true; + + expect(getSheetContentPaddingBottom({ bottom, fullContainer: true, scrollEnabled: false })).toBe(bottom + HANDLE_HEIGHT); + }); + + it('returns the safe-area bottom on Android for a scrollable full-container sheet', () => { + mockIsAndroid = true; + + expect(getSheetContentPaddingBottom({ bottom, fullContainer: true, scrollEnabled: true })).toBe(bottom); + }); + + it('returns the safe-area bottom on Android when hugging content', () => { + mockIsAndroid = true; + + expect(getSheetContentPaddingBottom({ bottom, fullContainer: true, hugContent: true, scrollEnabled: false })).toBe(bottom); + }); + + it('returns the safe-area bottom on Android for a regular sheet', () => { + mockIsAndroid = true; + + expect(getSheetContentPaddingBottom({ bottom })).toBe(bottom); + }); +}); diff --git a/app/containers/ActionSheet/useActionSheetDetents.ts b/app/containers/ActionSheet/useActionSheetDetents.ts index 5bc2b90abf..be9ecd767e 100644 --- a/app/containers/ActionSheet/useActionSheetDetents.ts +++ b/app/containers/ActionSheet/useActionSheetDetents.ts @@ -1,10 +1,27 @@ import type { SheetDetent } from '@lodev09/react-native-true-sheet'; import { useMemo } from 'react'; +import { isAndroid } from '../../lib/methods/helpers'; + const ACTION_SHEET_MIN_HEIGHT_FRACTION = 0.15; const ACTION_SHEET_MAX_HEIGHT_FRACTION = 0.75; const SCROLL_ENABLED_THRESHOLD = 0.6; export const HANDLE_HEIGHT = 28; +const SHEET_CONTENT_MIN_BOTTOM_PADDING = 32; + +export const getSheetContentPaddingBottom = ({ + bottom, + fullContainer, + hugContent, + scrollEnabled +}: { + bottom: number; + fullContainer?: boolean; + hugContent?: boolean; + scrollEnabled?: boolean; +}): number => + Math.max(SHEET_CONTENT_MIN_BOTTOM_PADDING, bottom) + + (isAndroid && fullContainer && !hugContent && !scrollEnabled ? HANDLE_HEIGHT : 0); function normalizeSnapsToDetents(snaps: (string | number)[]): number[] { return snaps diff --git a/app/containers/EmojiPicker/index.tsx b/app/containers/EmojiPicker/index.tsx index 02fba83816..11987f26e8 100644 --- a/app/containers/EmojiPicker/index.tsx +++ b/app/containers/EmojiPicker/index.tsx @@ -62,7 +62,7 @@ const EmojiPicker = ({ return ( setParentWidth(e.nativeEvent.layout.width)}> {searching ? ( diff --git a/app/containers/MessageComposer/components/Attachments/__snapshots__/AttachmentActionSheet.test.tsx.snap b/app/containers/MessageComposer/components/Attachments/__snapshots__/AttachmentActionSheet.test.tsx.snap index d55336309d..3664b4b313 100644 --- a/app/containers/MessageComposer/components/Attachments/__snapshots__/AttachmentActionSheet.test.tsx.snap +++ b/app/containers/MessageComposer/components/Attachments/__snapshots__/AttachmentActionSheet.test.tsx.snap @@ -11,14 +11,9 @@ exports[`Story Snapshots: File should match snapshot 1`] = ` > { const renderTabItem = (tab: IRoute, color: string) => { if (tab.key === 'all') { return ( - + {I18n.t('All')} ); } if (tab.emoji) { return ( - + {tab.usernames?.length}