diff --git a/app/views/RoomView/List/components/List.test.tsx b/app/views/RoomView/List/components/List.test.tsx
new file mode 100644
index 00000000000..d06e15a0255
--- /dev/null
+++ b/app/views/RoomView/List/components/List.test.tsx
@@ -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(
+
+ item.id}
+ renderItem={() => null}
+ />
+
+ );
+
+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');
+ });
+});
diff --git a/app/views/RoomsListView/index.tsx b/app/views/RoomsListView/index.tsx
index 0ec091befb3..a7b8df3b019 100644
--- a/app/views/RoomsListView/index.tsx
+++ b/app/views/RoomsListView/index.tsx
@@ -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';
@@ -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';
@@ -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';
@@ -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);
@@ -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 ;
- }
+ const getItemType = useCallback((item: IRoomItem) => (item.separator ? ITEM_TYPE_SECTION : ITEM_TYPE_ROOM), []);
+
+ const renderItem = useCallback(
+ ({ item }: { item: IRoomItem }) => {
+ if (item.separator) {
+ return ;
+ }
- const id = item.search && item.t === 'd' ? item._id : getUidDirectMessage(item);
- // TODO: move to RoomItem
- const swipeEnabled = !(item?.search || item?.joinCodeRequired || item?.outside);
-
- return (
-
- );
- };
+ const id = item.search && item.t === 'd' ? item._id : getUidDirectMessage(item);
+ // TODO: move to RoomItem
+ const swipeEnabled = !(item?.search || item?.joinCodeRequired || item?.outside);
+
+ return (
+
+ );
+ },
+ [username, showLastMessage, onPressItem, isMasterDetail, width, useRealName, subscribedRoom, showAvatar, displayMode]
+ );
if (searchEnabled) {
if (searching) {
@@ -128,21 +136,19 @@ const RoomsListView = memo(function RoomsListView() {
}
return (
- `${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={}
- windowSize={9}
onEndReachedThreshold={0.5}
keyboardDismissMode={isIOS ? 'on-drag' : 'none'}
+ drawDistance={300}
/>
);
});
diff --git a/docs/FLASHLIST_MIGRATION.md b/docs/FLASHLIST_MIGRATION.md
new file mode 100644
index 00000000000..55a64a5db60
--- /dev/null
+++ b/docs/FLASHLIST_MIGRATION.md
@@ -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.
diff --git a/package.json b/package.json
index 45c7b5e50e1..098cdfefeb4 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/yarn.lock b/yarn.lock
index 4a19e871830..e537e04410b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"