Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions .maestro/scripts/data-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -261,6 +276,7 @@ output.utils = {
sendMessage,
getProfileInfo,
post,
reactAsNewUsers,
login,
getDeepLink,
createDM,
Expand Down
65 changes: 65 additions & 0 deletions .maestro/tests/room/reaction-list-scroll-to-last-user.yaml
Original file line number Diff line number Diff line change
@@ -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}
Comment thread
Rohit3523 marked this conversation as resolved.
commands:
- swipe:
from:
id: 'usersList-:thumbsup:'
direction: up
- runFlow:
when:
visible:
text: ${output.reactors[14].username}
commands:
- evalScript: ${output.userFound = 1}
4 changes: 4 additions & 0 deletions .sniffler/test-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
21 changes: 15 additions & 6 deletions app/containers/ActionSheet/ActionSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,155 +11,164 @@
} 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';

export const ACTION_SHEET_ANIMATION_DURATION = 250;

const ActionSheet = memo(
forwardRef(({ children }: { children: ReactElement }, ref) => {
const { colors } = useTheme();
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const sheetRef = useRef<TrueSheet>(null);
const handleRef = useRef<View>(null);
const [data, setData] = useState<TActionSheetOptions>({} as TActionSheetOptions);
const [isVisible, setIsVisible] = useState(false);
const [contentHeight, setContentHeight] = useState(0);
const { bottom } = useSafeAreaInsets();
const onCloseSnapshotRef = useRef<TActionSheetOptions['onClose']>(undefined);

const itemHeight = useActionSheetItemHeight();

const handleContentLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
setContentHeight(layout.height);
};

const hide = () => {
if (!isVisible) return;
sheetRef.current?.dismiss();
Keyboard.dismiss();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};

const show = (options: TActionSheetOptions) => {
setData(options);
setIsVisible(true);
Keyboard.dismiss();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onCloseSnapshotRef.current = options.onClose;
sheetRef.current?.present();
};

useBackHandler(() => {
if (isVisible) {
hide();
}
return isVisible;
});

useImperativeHandle(ref, () => ({
showActionSheet: show,
hideActionSheet: hide
}));

const focusHandle = () => {
const node = findNodeHandle(handleRef.current);
if (node) AccessibilityInfo.setAccessibilityFocus(node);
};

const onDidPresent = () => {
// On Android the bottom sheet is hosted in a separate window; TalkBack
// needs a moment after the present animation before it can target nodes
// inside it, so defer the focus call slightly.
if (isAndroid) {
setTimeout(focusHandle, 300);
return;
}
focusHandle();
};

const renderHeader = () => (
<GestureHandlerRootView style={{ flex: 0 }}>
<Handle ref={handleRef} onPress={hide} />
{isValidElement(data?.customHeader) ? data.customHeader : null}
</GestureHandlerRootView>
);

const onDidDismiss = () => {
setIsVisible(false);
// Keep contentHeight to avoid flickering on next show
const snapshotOnClose = onCloseSnapshotRef.current;
onCloseSnapshotRef.current = undefined;
snapshotOnClose?.();
};

const isPortrait = windowHeight > windowWidth;
const effectiveSnaps = (isPortrait ? data?.portraitSnaps : data?.landscapeSnaps) || data?.snaps;

const { detents, maxHeight, scrollEnabled } = useActionSheetDetents({
windowHeight,
itemHeight,
optionsLength: data?.options?.length || 0,
snaps: effectiveSnaps,
headerHeight: data?.headerHeight,
hasCancel: data?.hasCancel,
contentHeight
});

const hasOptions = !!data?.options?.length;
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
? (() => {
const snap = effectiveSnaps[0];
const fraction = typeof snap === 'number' ? Math.min(1, Math.max(0.1, snap)) : (parseFloat(String(snap)) || 50) / 100;
return Math.max(0, windowHeight * fraction - HANDLE_HEIGHT);
})()
: undefined;

return (
<>
{children}
<TrueSheet
ref={sheetRef}
detents={detents}
maxHeight={maxHeight}
backgroundColor={colors.surfaceLight}
cornerRadius={16}
dimmed
grabber={false}
draggable={!disableContentPanning}
header={renderHeader()}
scrollable={isScrollable}
style={styles.container}
onDidPresent={onDidPresent}
onDidDismiss={onDidDismiss}>
<GestureHandlerRootView style={styles.contentContainer}>
<BottomSheetContent
options={data?.options}
hide={hide}
hasCancel={data?.hasCancel}
onLayout={handleContentLayout}
fullContainer={data.fullContainer}
hugContent={data.hugContent}
contentMinHeight={isIOS ? contentMinHeight : undefined}
scrollEnabled={scrollEnabled}>
scrollEnabled={contentScrollEnabled}>
{data?.children}
</BottomSheetContent>
</GestureHandlerRootView>
</TrueSheet>
</>
);
})

Check warning on line 171 in app/containers/ActionSheet/ActionSheet.tsx

View workflow job for this annotation

GitHub Actions / ESLint and Test / run-eslint-and-test

eslint(complexity)

function has a complexity of 25. Maximum allowed is 20.
);

export default ActionSheet;
4 changes: 3 additions & 1 deletion app/containers/ActionSheet/BottomSheetContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -78,7 +80,7 @@ const BottomSheetContent = memo(
return (
<View
testID='action-sheet'
style={fullContainer && !(hugContent && isAndroid) ? [styles.fullContainer, minHeightStyle] : undefined}
style={[fullContainer && !hugContent ? styles.fullContainer : undefined, minHeightStyle, { paddingBottom }]}
onLayout={onLayout}>
{children}
</View>
Expand Down
54 changes: 53 additions & 1 deletion app/containers/ActionSheet/useActionSheetDetents.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
});
17 changes: 17 additions & 0 deletions app/containers/ActionSheet/useActionSheetDetents.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/containers/EmojiPicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const EmojiPicker = ({

return (
<View
style={[styles.emojiPickerContainer, { marginBottom: bottom, backgroundColor: colors.surfaceLight }]}
style={[styles.emojiPickerContainer, { marginBottom: bottomSheet ? 0 : bottom, backgroundColor: colors.surfaceLight }]}
onLayout={e => setParentWidth(e.nativeEvent.layout.width)}>
{searching ? (
<EmojiCategory
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Image, StyleSheet, Text, TextInput, View } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import Button from '../../../Button';
import { CustomIcon } from '../../../CustomIcon';
Expand All @@ -11,11 +10,8 @@ import { useActionSheet } from '../../../ActionSheet';
import { useTheme } from '../../../../theme';
import { useAltTextSupported } from '../../../../lib/hooks/useAltTextSupported';
import sharedStyles from '../../../../views/Styles';
import { isAndroid } from '../../../../lib/methods/helpers';

const PREVIEW_HEIGHT = 240;
// Height of the action sheet's handle/grabber row.
const ANDROID_SHEET_HANDLE_HEIGHT = 48;

const styles = StyleSheet.create({
container: {
Expand Down Expand Up @@ -99,18 +95,10 @@ export const AttachmentActionSheet = ({ attachment, onSave }: AttachmentActionSh
const [altText, setAltText] = useState(attachment.altText || '');
const isImage = attachment.mime?.startsWith('image/');
const showAltTextInput = altTextSupported && isImage;
const { bottom, top } = useSafeAreaInsets();
// Android: TrueSheet renders edge-to-edge and doesn't subtract system insets from the
// content area, so the scroll content needs to reserve space at the bottom for:
// - `bottom`: the gesture navigation / home bar safe area
// - `top`: the status bar inset, which TrueSheet adds to the sheet's total height
// - handle: the sheet's drag handle/grabber row above the content
// Without this padding the Save button ends up clipped behind the sheet's edges.
const paddingBottom = isAndroid ? bottom + top + ANDROID_SHEET_HANDLE_HEIGHT : undefined;
return (
<KeyboardAwareScrollView
style={styles.container}
contentContainerStyle={[styles.contentContainer, { paddingBottom }]}
contentContainerStyle={styles.contentContainer}
keyboardShouldPersistTaps='handled'
showsVerticalScrollIndicator={false}>
<Text numberOfLines={1} style={[styles.title, { color: colors.fontDefault }]}>
Expand Down
Loading
Loading