Skip to content
Open
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
39 changes: 39 additions & 0 deletions app/views/RoomView/List/components/List.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React, { createRef } from 'react';
import { render } from '@testing-library/react-native';

import List from './List';
import { RoomContext } from '../../context';
import { type TAnyMessageModel } from '../../../../definitions';

const messages = [{ id: '1' }, { id: '2' }] as TAnyMessageModel[];

const renderList = (isAutocompleteVisible: boolean) =>
render(
<RoomContext.Provider value={{ room: {}, selectedMessages: [], isAutocompleteVisible }}>
<List
listRef={createRef()}
jumpToBottom={jest.fn()}
data={messages}
keyExtractor={item => item.id}
renderItem={() => null}
/>
</RoomContext.Provider>
);

describe('RoomView List accessibility', () => {
it('hides message list from accessibility tree while autocomplete is visible', () => {
const { UNSAFE_getByProps } = renderList(true);
const list = UNSAFE_getByProps({ testID: 'room-view-messages' });

expect(list.props.accessibilityElementsHidden).toBe(true);
expect(list.props.importantForAccessibility).toBe('no-hide-descendants');
});

it('keeps message list visible to accessibility tree while autocomplete is hidden', () => {
const { UNSAFE_getByProps } = renderList(false);
const list = UNSAFE_getByProps({ testID: 'room-view-messages' });

expect(list.props.accessibilityElementsHidden).toBe(false);
expect(list.props.importantForAccessibility).toBe('yes');
});
});
108 changes: 57 additions & 51 deletions app/views/RoomsListView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useNavigation } from '@react-navigation/native';
import React, { memo, useContext, useEffect } from 'react';
import { BackHandler, FlatList, RefreshControl } from 'react-native';
import React, { memo, useCallback, useContext, useEffect } from 'react';
import { BackHandler, RefreshControl } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { shallowEqual } from 'react-redux';
import { FlashList } from '@shopify/flash-list';

import ActivityIndicator from '../../containers/ActivityIndicator';
import BackgroundContainer from '../../containers/BackgroundContainer';
Expand All @@ -13,7 +14,7 @@ import { SupportedVersionsExpired } from '../../containers/SupportedVersions';
import i18n from '../../i18n';
import { MAX_SIDEBAR_WIDTH } from '../../lib/constants/tablet';
import { useAppSelector } from '../../lib/hooks/useAppSelector';
import { getRoomAvatar, getRoomTitle, getUidDirectMessage, isIOS, isRead, isTablet } from '../../lib/methods/helpers';
import { getRoomAvatar, getRoomTitle, getUidDirectMessage, isIOS, isRead } from '../../lib/methods/helpers';
import { goRoom } from '../../lib/methods/helpers/goRoom';
import { events, logEvent } from '../../lib/methods/helpers/log';
import { getUserSelector } from '../../selectors/login';
Expand All @@ -22,13 +23,13 @@ import Container from './components/Container';
import ListHeader from './components/ListHeader';
import SectionHeader from './components/SectionHeader';
import RoomsSearchProvider, { RoomsSearchContext } from './contexts/RoomsSearchProvider';
import { useGetItemLayout } from './hooks/useGetItemLayout';
import { useHeader } from './hooks/useHeader';
import { useRefresh } from './hooks/useRefresh';
import { useSubscriptions } from './hooks/useSubscriptions';
import styles from './styles';

const INITIAL_NUM_TO_RENDER = isTablet ? 20 : 12;
const ITEM_TYPE_SECTION = 'section';
const ITEM_TYPE_ROOM = 'room';

const RoomsListView = memo(function RoomsListView() {
'use memo';
Expand All @@ -44,7 +45,6 @@ const RoomsListView = memo(function RoomsListView() {
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
const navigation = useNavigation();
const { width } = useSafeAreaFrame();
const getItemLayout = useGetItemLayout();
const { subscriptions, loading } = useSubscriptions();
const subscribedRoom = useAppSelector(state => state.room.subscribedRoom);
const changingServer = useAppSelector(state => state.server.changingServer);
Expand All @@ -63,48 +63,56 @@ const RoomsListView = memo(function RoomsListView() {
return () => subscription.remove();
}, [searchEnabled]);

const onPressItem = (item = {} as IRoomItem) => {
if (!navigation.isFocused()) {
return;
}
if (item.rid === subscribedRoom) {
return;
}
const onPressItem = useCallback(
(item = {} as IRoomItem) => {
if (!navigation.isFocused()) {
return;
}
if (item.rid === subscribedRoom) {
return;
}

logEvent(events.RL_GO_ROOM);
stopSearch();
goRoom({ item, isMasterDetail });
};
logEvent(events.RL_GO_ROOM);
stopSearch();
goRoom({ item, isMasterDetail });
},
[navigation, subscribedRoom, stopSearch, isMasterDetail]
);

const renderItem = ({ item }: { item: IRoomItem }) => {
if (item.separator) {
return <SectionHeader header={item.rid} />;
}
const getItemType = useCallback((item: IRoomItem) => (item.separator ? ITEM_TYPE_SECTION : ITEM_TYPE_ROOM), []);

const renderItem = useCallback(
({ item }: { item: IRoomItem }) => {
if (item.separator) {
return <SectionHeader header={item.rid} />;
}

const id = item.search && item.t === 'd' ? item._id : getUidDirectMessage(item);
// TODO: move to RoomItem
const swipeEnabled = !(item?.search || item?.joinCodeRequired || item?.outside);

return (
<RoomItem
item={item}
id={id}
username={username}
showLastMessage={showLastMessage}
onPress={onPressItem}
// TODO: move to RoomItem
width={isMasterDetail ? MAX_SIDEBAR_WIDTH : width}
useRealName={useRealName}
getRoomTitle={getRoomTitle}
getRoomAvatar={getRoomAvatar}
getIsRead={isRead}
isFocused={subscribedRoom === item.rid}
swipeEnabled={swipeEnabled}
showAvatar={showAvatar}
displayMode={displayMode}
/>
);
};
const id = item.search && item.t === 'd' ? item._id : getUidDirectMessage(item);
// TODO: move to RoomItem
const swipeEnabled = !(item?.search || item?.joinCodeRequired || item?.outside);

return (
<RoomItem
item={item}
id={id}
username={username}
showLastMessage={showLastMessage}
onPress={onPressItem}
// TODO: move to RoomItem
width={isMasterDetail ? MAX_SIDEBAR_WIDTH : width}
useRealName={useRealName}
getRoomTitle={getRoomTitle}
getRoomAvatar={getRoomAvatar}
getIsRead={isRead}
isFocused={subscribedRoom === item.rid}
swipeEnabled={swipeEnabled}
showAvatar={showAvatar}
displayMode={displayMode}
/>
);
},
[username, showLastMessage, onPressItem, isMasterDetail, width, useRealName, subscribedRoom, showAvatar, displayMode]
);

if (searchEnabled) {
if (searching) {
Expand All @@ -128,21 +136,19 @@ const RoomsListView = memo(function RoomsListView() {
}

return (
<FlatList
<FlashList
data={searchEnabled ? searchResults : subscriptions}
extraData={searchEnabled ? searchResults : subscriptions}
extraData={[searchEnabled ? searchResults : subscriptions, subscribedRoom]}
getItemType={getItemType}
keyExtractor={item => `${item.rid}-${searchEnabled}`}
style={[styles.list, { backgroundColor: colors.surfaceRoom }]}
renderItem={renderItem}
ListHeaderComponent={ListHeader}
getItemLayout={getItemLayout}
removeClippedSubviews={isIOS}
keyboardShouldPersistTaps='always'
initialNumToRender={INITIAL_NUM_TO_RENDER}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.fontSecondaryInfo} />}
windowSize={9}
onEndReachedThreshold={0.5}
keyboardDismissMode={isIOS ? 'on-drag' : 'none'}
drawDistance={300}
/>
);
});
Expand Down
26 changes: 26 additions & 0 deletions docs/FLASHLIST_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# FlashList migration

The rooms list (`RoomsListView`) uses `@shopify/flash-list` instead of `FlatList` for better performance on Android.

The room message list (`RoomView`) continues to use `Animated.FlatList` because it relies on the `inverted` prop and `onScrollToIndexFailed` which are not supported by FlashList v2.

## Comparing CPU usage (Android)

On a physical device with the app installed:

1. Reset and capture baseline (optional, if comparing two builds):
```bash
adb shell dumpsys gfxinfo chat.rocket.reactnative reset
```
2. Use the app (scroll rooms list, open a room, scroll messages, receive messages, jump to message, scroll to bottom).
3. Dump CPU and graphics stats:
```bash
adb shell dumpsys cpuinfo | grep -E "chat.rocket|Total"
adb shell dumpsys gfxinfo chat.rocket.reactnative
```
4. From `gfxinfo` compare:
- **Janky frames** (lower is better)
- **50th / 90th / 95th / 99th percentile** frame times (lower is better)
- **Number Slow UI thread** (lower is better)

Run the same flow on the same device for a pre-migration build and the FlashList build, then compare the numbers.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@rocket.chat/mobile-crypto": "RocketChat/rocket.chat-mobile-crypto",
"@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#mobile",
"@rocket.chat/ui-kit": "0.31.19",
"@shopify/flash-list": "^2.2.2",
"bytebuffer": "5.0.1",
"color2k": "1.2.4",
"dayjs": "^1.11.18",
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5042,6 +5042,11 @@
resolved "https://registry.yarnpkg.com/@rocket.chat/ui-kit/-/ui-kit-0.31.19.tgz#737103123bc7e635382217eef75965b7e0f44703"
integrity sha512-8zRKQ5CoC4hIuYHVheO0d7etX9oizmM18fu99r5s/deciL/0MRWocdb4H/QsmbsNrkKCO6Z6wr7f9zzJCNTRHg==

"@shopify/flash-list@^2.2.2":
version "2.2.2"
resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.2.2.tgz#552c4959f2ab3bc5cc69fac1e3fb514b5502e6ad"
integrity sha512-YrvLBK5FCpvuX+d9QvJvjVqyi4eBUaEamkyfh9CjPdF6c+AukP0RSBh97qHyTwOEaVq21A5ukwgyWMDIbmxpmQ==

"@sideway/address@^4.1.5":
version "4.1.5"
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
Expand Down