diff --git a/app/containers/message/index.tsx b/app/containers/message/index.tsx index 31522bd7f5c..456f15c6c2a 100644 --- a/app/containers/message/index.tsx +++ b/app/containers/message/index.tsx @@ -451,6 +451,7 @@ class MessageContainer extends React.Component + {/* @ts-ignore*/} - ); } diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index 2d8b0c081e7..7cefb3cb54d 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -1,11 +1,9 @@ -import React, { useState } from 'react'; -import { StyleSheet, View } from 'react-native'; -import Animated, { runOnJS, useAnimatedScrollHandler } from 'react-native-reanimated'; +import React, { useEffect, useMemo, useState } from 'react'; +import { type NativeScrollEvent, type NativeSyntheticEvent, StyleSheet, View, Keyboard } from 'react-native'; +import { FlashList } from '@shopify/flash-list'; -import { isIOS } from '../../../../lib/methods/helpers'; -import scrollPersistTaps from '../../../../lib/methods/helpers/scrollPersistTaps'; -import NavBottomFAB from './NavBottomFAB'; import { type IListProps } from '../definitions'; +import NavBottomFAB from './NavBottomFAB'; import { SCROLL_LIMIT } from '../constants'; import { useRoomContext } from '../../context'; @@ -21,37 +19,54 @@ const styles = StyleSheet.create({ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { const [visible, setVisible] = useState(false); const { isAutocompleteVisible } = useRoomContext(); - const scrollHandler = useAnimatedScrollHandler({ - onScroll: event => { - if (event.contentOffset.y > SCROLL_LIMIT) { - runOnJS(setVisible)(true); - } else { - runOnJS(setVisible)(false); - } + + const maintainVisibleContentPositionConfig = useMemo( + () => ({ + autoscrollToBottomThreshold: 0.1, + startRenderingFromBottom: true, + animateAutoScrollToBottom: false + }), + [] + ); + + const onScrollHandler = (e: NativeSyntheticEvent) => { + const currentScroll = Math.round(e.nativeEvent?.contentSize?.height) - Math.round(e.nativeEvent?.contentOffset.y); + const layoutLimit = e.nativeEvent.layoutMeasurement.height + SCROLL_LIMIT; + + if (layoutLimit < currentScroll) { + setVisible(true); + } else { + setVisible(false); } - }); + }; + + // Scroll to end when keyboard opens + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { + if (listRef?.current) { + listRef.current.scrollToEnd({ animated: true }); + } + }); + + return () => { + keyboardDidShowListener.remove(); + }; + }, [listRef]); return ( - {/* @ts-ignore */} - item.id} contentContainerStyle={styles.contentContainer} style={styles.list} - inverted - removeClippedSubviews={isIOS} - initialNumToRender={7} - onEndReachedThreshold={0.5} - maxToRenderPerBatch={5} - windowSize={10} + onScroll={onScrollHandler} scrollEventThrottle={16} - onScroll={scrollHandler} + keyboardShouldPersistTaps='handled' + maintainVisibleContentPosition={maintainVisibleContentPositionConfig} {...props} - {...scrollPersistTaps} /> diff --git a/app/views/RoomView/List/definitions.ts b/app/views/RoomView/List/definitions.ts index 7ed5c4d929e..9f8b330d6a2 100644 --- a/app/views/RoomView/List/definitions.ts +++ b/app/views/RoomView/List/definitions.ts @@ -1,14 +1,13 @@ import { type RefObject } from 'react'; -import { type FlatListProps } from 'react-native'; -import { type FlatList } from 'react-native-gesture-handler'; +import { type FlashListProps, type FlashListRef } from '@shopify/flash-list'; import { type TAnyMessageModel } from '../../../definitions'; -export type TListRef = RefObject | null>; +export type TListRef = RefObject> | undefined; export type TMessagesIdsRef = RefObject; -export interface IListProps extends FlatListProps { +export interface IListProps extends FlashListProps { listRef: TListRef; jumpToBottom: () => void; } diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts index da21e3193be..08c5edf9ea9 100644 --- a/app/views/RoomView/List/hooks/useMessages.ts +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -81,7 +81,8 @@ export const useMessages = ({ } readThread(); - setMessages(newMessages); + const reversedList = newMessages.reverse(); + setMessages(reversedList); messagesIds.current = newMessages.map(m => m.id); }); }, [rid, tmid, showMessageInMainThread, serverVersion, hideSystemMessages]); diff --git a/app/views/RoomView/List/hooks/useScroll.ts b/app/views/RoomView/List/hooks/useScroll.ts index b83fe5cdb32..d9fadab9602 100644 --- a/app/views/RoomView/List/hooks/useScroll.ts +++ b/app/views/RoomView/List/hooks/useScroll.ts @@ -1,8 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { type ViewToken, type ViewabilityConfigCallbackPairs } from 'react-native'; +import { type ViewToken } from 'react-native'; -import { type IListContainerRef, type IListProps, type TListRef, type TMessagesIdsRef } from '../definitions'; -import { VIEWABILITY_CONFIG } from '../constants'; +import { type IListContainerRef, type TListRef, type TMessagesIdsRef } from '../definitions'; export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; messagesIds: TMessagesIdsRef }) => { const [highlightedMessageId, setHighlightedMessageId] = useState(null); @@ -21,21 +20,9 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message ); const jumpToBottom = useCallback(() => { - listRef.current?.scrollToOffset({ offset: -100 }); + listRef?.current?.scrollToEnd(); }, [listRef]); - const onViewableItemsChanged: IListProps['onViewableItemsChanged'] = ({ viewableItems: vi }) => { - viewableItems.current = vi; - }; - - const viewabilityConfigCallbackPairs = useRef([ - { onViewableItemsChanged, viewabilityConfig: VIEWABILITY_CONFIG } - ]); - - const handleScrollToIndexFailed: IListProps['onScrollToIndexFailed'] = params => { - listRef.current?.scrollToIndex({ index: params.highestMeasuredFrameIndex, animated: false }); - }; - const setHighlightTimeout = () => { if (highlightTimeout.current) { clearTimeout(highlightTimeout.current); @@ -59,7 +46,7 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message // if found message, scroll to it if (index !== -1) { - listRef.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 }); + listRef?.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 }); // wait for scroll animation to finish await new Promise(res => setTimeout(res, 300)); @@ -76,7 +63,7 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message resolve(); } else { // if message not on state yet, scroll to top, so it triggers onEndReached and try again - listRef.current?.scrollToEnd(); + listRef?.current?.scrollToEnd(); await setTimeout(() => resolve(jumpToMessage(messageId)), 600); } }); @@ -98,8 +85,6 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message jumpToBottom, jumpToMessage, cancelJumpToMessage, - viewabilityConfigCallbackPairs, - handleScrollToIndexFailed, highlightedMessageId }; }; diff --git a/app/views/RoomView/List/index.tsx b/app/views/RoomView/List/index.tsx index 2b7c3839ee9..629424ee769 100644 --- a/app/views/RoomView/List/index.tsx +++ b/app/views/RoomView/List/index.tsx @@ -15,14 +15,10 @@ const ListContainer = forwardRef( serverVersion, hideSystemMessages }); - const { - jumpToBottom, - jumpToMessage, - cancelJumpToMessage, - viewabilityConfigCallbackPairs, - handleScrollToIndexFailed, - highlightedMessageId - } = useScroll({ listRef, messagesIds }); + const { jumpToBottom, jumpToMessage, cancelJumpToMessage, highlightedMessageId } = useScroll({ + listRef, + messagesIds + }); const onEndReached = useDebounce(() => { fetchMessages(); @@ -33,7 +29,7 @@ const ListContainer = forwardRef( cancelJumpToMessage })); - const renderItem: IListProps['renderItem'] = ({ item, index }) => renderRow(item, messages[index + 1], highlightedMessageId); + const renderItem: IListProps['renderItem'] = ({ item, index }) => renderRow(item, messages[index - 1], highlightedMessageId); return ( <> @@ -42,14 +38,9 @@ const ListContainer = forwardRef( listRef={listRef} data={messages} renderItem={renderItem} - onEndReached={onEndReached} - onScrollToIndexFailed={handleScrollToIndexFailed} - viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current} + onStartReached={onEndReached} + onStartReachedThreshold={0.9} jumpToBottom={jumpToBottom} - maintainVisibleContentPosition={{ - minIndexForVisible: 0, - autoscrollToTopThreshold: 0 - }} /> ); diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index a2d595f2d09..52b24e5d265 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -199,7 +199,7 @@ class RoomView extends React.Component { this.messageComposerRef = React.createRef(); this.list = React.createRef(); - this.flatList = React.createRef(); + this.flatList = React.createRef() as TListRef; this.joinCode = React.createRef(); this.mounted = false; diff --git a/app/views/RoomsListView/index.tsx b/app/views/RoomsListView/index.tsx index 0ec091befb3..e1966b4654c 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 { 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,9 @@ 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 RoomsListView = memo(function RoomsListView() { 'use memo'; @@ -44,7 +41,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); @@ -128,19 +124,15 @@ 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'} /> diff --git a/metro.config.js b/metro.config.js index 234dd67481b..69e6277f140 100644 --- a/metro.config.js +++ b/metro.config.js @@ -12,9 +12,16 @@ const config = { unstable_allowRequireContext: true }, resolver: { - // When running E2E tests, prioritize .mock.ts files for app code - // Note: react-native-mmkv's internal mock file is disabled via patch-package - sourceExts: process.env.RUNNING_E2E_TESTS === 'true' ? ['mock.ts', ...sourceExts] : sourceExts + sourceExts: process.env.RUNNING_E2E_TESTS ? ['mock.ts', ...sourceExts] : sourceExts, + // Force flash-list to use source files so our patch is applied + resolveRequest: (context, moduleName, platform) => { + // Redirect flash-list dist imports to src + if (moduleName.startsWith('@shopify/flash-list')) { + const newModuleName = moduleName.replace('@shopify/flash-list', '@shopify/flash-list/src'); + return context.resolveRequest(context, newModuleName, platform); + } + return context.resolveRequest(context, moduleName, platform); + } } }; diff --git a/package.json b/package.json index d1d72997c88..9d67543d7fa 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@react-native-firebase/app": "^21.12.2", "@react-native-firebase/crashlytics": "^21.12.2", "@react-native-masked-view/masked-view": "^0.3.1", - "@react-native-picker/picker": "^2.11.0", + "@react-native-picker/picker": "2.11.0", "@react-native/codegen": "^0.80.0", "@react-navigation/drawer": "^7.5.5", "@react-navigation/elements": "^2.6.1", @@ -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.0.3", "bytebuffer": "5.0.1", "color2k": "1.2.4", "dayjs": "^1.11.18", diff --git a/patches/@shopify+flash-list+2.0.3.patch b/patches/@shopify+flash-list+2.0.3.patch new file mode 100644 index 00000000000..4c03446e9a5 --- /dev/null +++ b/patches/@shopify+flash-list+2.0.3.patch @@ -0,0 +1,659 @@ +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 5447a5c..17ba1a7 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -15,6 +15,7 @@ import React, { + import { + Animated, + I18nManager, ++ InteractionManager, + NativeScrollEvent, + NativeSyntheticEvent, + } from "react-native"; +@@ -97,6 +98,21 @@ const RecyclerViewComponent = ( + const firstChildViewRef = useRef(null); + const containerViewSizeRef = useRef(undefined); + const pendingChildIds = useRef>(new Set()).current; ++ const hasInitialScrolledToBottomRef = useRef(false); ++ const lastDataLengthRef = useRef(0); ++ ++ // Track user scroll state ++ const isScrolledByUserRef = useRef(false); ++ const lastScrollOffsetRef = useRef(0); ++ const isUserTouchingRef = useRef(false); // Track if user is actively touching/dragging ++ ++ // Track content height and layout stability for scroll-to-bottom ++ const lastContentHeightRef = useRef(0); ++ const lastViewportSizeRef = useRef(0); // Track viewport size for bottom detection ++ const layoutStabilizationTimeoutRef = useRef | null>(null); ++ const scrollToBottomAttemptsRef = useRef(0); ++ const lastLayoutStableTimeRef = useRef(0); ++ const isVeryFirstScrollAttemptRef = useRef(true); // Track if this is the very first scroll attempt + + // Track scroll position + const scrollY = useRef(new Animated.Value(0)).current; +@@ -133,12 +149,107 @@ const RecyclerViewComponent = ( + // Initialize view holder collection ref + const viewHolderCollectionRef = useRef(null); + ++ // Compute shouldRenderFromBottom early so it can be used in callbacks ++ const shouldRenderFromBottom = ++ recyclerViewManager.getDataLength() > 0 && ++ (maintainVisibleContentPosition?.startRenderingFromBottom ?? false); ++ + // Hook to handle list loading + useOnListLoad(recyclerViewManager, onLoad); + + // Hook to detect when scrolling reaches list bounds + const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); + ++ // Layout effect to detect when data length changes significantly and scroll to new bottom ++ // This handles the case where app opens with 1 item, then fetches 50 items ++ // Using useLayoutEffect to scroll synchronously before paint to prevent flickering ++ useLayoutEffect(() => { ++ const currentDataLength = recyclerViewManager.getDataLength(); ++ const previousDataLength = lastDataLengthRef.current; ++ ++ // Detect significant data change (e.g., 1 -> 50 items) ++ // This happens when initial data is small, then more data is loaded ++ const isSignificantDataChange = ++ previousDataLength > 0 && ++ currentDataLength > previousDataLength && ++ (currentDataLength - previousDataLength) >= 5; // Threshold for "significant" change ++ ++ if ( ++ shouldRenderFromBottom && ++ isSignificantDataChange && ++ !isScrolledByUserRef.current && ++ !isUserTouchingRef.current && ++ handlerMethods && ++ recyclerViewManager.getIsFirstLayoutComplete() ++ ) { ++ // Check if we're already at the bottom before scrolling ++ const cachedScrollOffset = lastScrollOffsetRef.current; ++ const cachedContentHeight = lastContentHeightRef.current; ++ const cachedViewportSize = lastViewportSizeRef.current; ++ ++ // Simple bottom detection: check if scroll offset + viewport is near content size ++ const threshold = 50; ++ const alreadyAtBottom = cachedContentHeight > 0 && ++ cachedViewportSize > 0 && ++ cachedScrollOffset + cachedViewportSize >= cachedContentHeight - threshold; ++ ++ if (alreadyAtBottom) { ++ console.log( ++ `[FlashList] Data length changed significantly (${previousDataLength} -> ${currentDataLength}), but already at bottom - skipping scroll` ++ ); ++ lastDataLengthRef.current = currentDataLength; ++ return; ++ } ++ ++ console.log( ++ `[FlashList] Data length changed significantly (${previousDataLength} -> ${currentDataLength}), scrolling to new bottom` ++ ); ++ ++ // Reset initial scroll state to allow scrolling to new bottom ++ hasInitialScrolledToBottomRef.current = false; ++ isVeryFirstScrollAttemptRef.current = true; ++ ++ // Scroll immediately in layout effect to prevent flickering ++ // This runs synchronously before paint ++ const newLastIndex = currentDataLength - 1; ++ if (newLastIndex >= 0 && recyclerViewManager.hasLayout()) { ++ try { ++ // Try to scroll to the new last index immediately ++ const layout = recyclerViewManager.getLayout(newLastIndex); ++ if (layout) { ++ const offset = horizontal ? layout.x : layout.y; ++ handlerMethods.scrollToOffset({ ++ offset, ++ animated: false, ++ skipFirstItemOffset: false, ++ }); ++ } else { ++ // If layout not ready, use scrollToEnd as fallback ++ handlerMethods.scrollToEnd({ animated: false }); ++ } ++ } catch (e) { ++ // Fallback to scrollToEnd if anything fails ++ handlerMethods.scrollToEnd({ animated: false }); ++ } ++ } else { ++ // Fallback to scrollToEnd if index is invalid ++ handlerMethods.scrollToEnd({ animated: false }); ++ } ++ } ++ ++ lastDataLengthRef.current = currentDataLength; ++ }, [data?.length, shouldRenderFromBottom, handlerMethods, recyclerViewManager, horizontal]); ++ ++ // Cleanup timeouts on unmount ++ React.useEffect(() => { ++ return () => { ++ if (layoutStabilizationTimeoutRef.current) { ++ clearTimeout(layoutStabilizationTimeoutRef.current); ++ layoutStabilizationTimeoutRef.current = null; ++ } ++ }; ++ }, []); ++ + const isHorizontalRTL = I18nManager.isRTL && horizontal; + + /** +@@ -182,34 +293,50 @@ const RecyclerViewComponent = ( + /** + * Effect to handle layout updates for list items + * This ensures proper positioning and recycling of items ++ * Also detects when items resize and compensates scroll position if needed + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + if (pendingChildIds.size > 0) { ++ console.log('[FlashList] Layout effect: Skipped - pending layout measurements'); + return; + } ++ ++ const dataLength = data?.length ?? 0; + const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { + const layout = measureItemLayout( + viewHolderRef.current!, + recyclerViewManager.tryGetLayout(index) + ); + +- // comapre height with stored layout +- // const storedLayout = recyclerViewManager.getLayout(index); +- // if ( +- // storedLayout.height !== layout.height && +- // storedLayout.isHeightMeasured +- // ) { +- // console.log( +- // "height changed", +- // index, +- // layout.height, +- // storedLayout.height +- // ); +- // } + return { index, dimensions: layout }; + }); + ++ // Detect if items at the bottom have resized ++ let bottomItemResized = false; ++ if (shouldRenderFromBottom && dataLength > 0) { ++ // Check items in the bottom 20% of the list for height changes ++ const bottomThreshold = Math.floor(dataLength * 0.8); ++ for (const { index, dimensions } of layoutInfo) { ++ if (index >= bottomThreshold) { ++ const storedLayout = recyclerViewManager.tryGetLayout(index); ++ if (storedLayout && storedLayout.isHeightMeasured) { ++ const heightChanged = areDimensionsNotEqual( ++ storedLayout.height, ++ dimensions.height ++ ); ++ if (heightChanged) { ++ bottomItemResized = true; ++ console.log( ++ `[FlashList] Layout effect: Bottom item #${index} height changed: ${storedLayout.height} -> ${dimensions.height}` ++ ); ++ break; ++ } ++ } ++ } ++ } ++ } ++ + const hasExceededMaxRendersWithoutCommit = + renderTimeTracker.hasExceededMaxRendersWithoutCommit(); + +@@ -217,18 +344,182 @@ const RecyclerViewComponent = ( + console.warn(WarningMessages.exceededMaxRendersWithoutCommit); + } + +- if ( +- recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) && +- !hasExceededMaxRendersWithoutCommit +- ) { ++ const layoutWasModified = recyclerViewManager.modifyChildrenLayout( ++ layoutInfo, ++ dataLength ++ ); ++ ++ if (layoutWasModified && !hasExceededMaxRendersWithoutCommit) { + // Trigger re-render if layout modifications were made ++ console.log('[FlashList] Layout effect: Layout modified, triggering re-render'); + setRenderId((prev) => prev + 1); + } else { + viewHolderCollectionRef.current?.commitLayout(); + applyOffsetCorrection(); ++ ++ // If bottom items resized and we should maintain bottom position, attempt scroll ++ if (bottomItemResized && shouldRenderFromBottom && !isScrolledByUserRef.current && !isUserTouchingRef.current) { ++ console.log('[FlashList] Layout effect: Bottom item resized, attempting scroll compensation'); ++ ++ // Clear any existing stabilization timeout ++ if (layoutStabilizationTimeoutRef.current) { ++ clearTimeout(layoutStabilizationTimeoutRef.current); ++ } ++ ++ // Mark layout as stable after a short delay to allow for multiple rapid changes ++ layoutStabilizationTimeoutRef.current = setTimeout(() => { ++ // Double-check user is still not touching before scrolling ++ if (!isUserTouchingRef.current && !isScrolledByUserRef.current) { ++ lastLayoutStableTimeRef.current = Date.now(); ++ attemptScrollToBottom('bottom-item-resized', true); ++ } ++ layoutStabilizationTimeoutRef.current = null; ++ }, 100); ++ } else { ++ // Mark layout as stable immediately if no bottom items resized ++ lastLayoutStableTimeRef.current = Date.now(); ++ } + } + }); + ++ /** ++ * Helper function to determine if the list is scrolled to the bottom ++ */ ++ const isAtBottom = useCallback( ++ (scrollOffset: number, contentSize: number, viewportSize: number): boolean => { ++ const threshold = 50; // pixels tolerance for "at bottom" detection ++ console.log('[FlashList] isAtBottom: scrollOffset', scrollOffset + viewportSize >= contentSize - threshold); ++ return scrollOffset + viewportSize >= contentSize - threshold; ++ }, ++ [] ++ ); ++ ++ /** ++ * Check if the list is currently at the bottom ++ * Uses cached scroll state first, falls back to measuring ScrollView if needed ++ */ ++ const checkIfAtBottom = useCallback((): boolean => { ++ // First try using cached scroll state (faster) ++ const cachedScrollOffset = lastScrollOffsetRef.current; ++ const cachedContentHeight = lastContentHeightRef.current; ++ const cachedViewportSize = lastViewportSizeRef.current; ++/* ++ if (cachedContentHeight > 0 && cachedViewportSize > 0) { ++ const atBottomUsingCache = isAtBottom(cachedScrollOffset, cachedContentHeight, cachedViewportSize); ++ console.log( ++ `[FlashList] isAtBottom : Using cached state - offset=${cachedScrollOffset.toFixed(2)}, content=${cachedContentHeight.toFixed(2)}, viewport=${cachedViewportSize.toFixed(2)}, atBottom=${atBottomUsingCache}` ++ ); ++ return atBottomUsingCache; ++ } */ ++ ++ // Fallback: Try to measure ScrollView directly (slower but more accurate) ++ if (scrollViewRef.current) { ++ try { ++ // @ts-ignore - accessing native methods ++ const scrollView = scrollViewRef.current.getScrollableNode?.() || scrollViewRef.current; ++ if (scrollView && typeof scrollView.measure === 'function') { ++ // For native ScrollView, we'd need to access scroll metrics differently ++ // Since this is complex, we'll rely on cached state being sufficient ++ // If cached state is unavailable, we'll be conservative and return false ++ console.log('[FlashList] checkIfAtBottom: Cached state unavailable, cannot measure - assuming not at bottom'); ++ return false; ++ } ++ } catch (e) { ++ console.log('[FlashList] checkIfAtBottom: Error measuring ScrollView - assuming not at bottom'); ++ return false; ++ } ++ } ++ ++ // If we can't determine, be conservative and assume we're not at bottom ++ console.log('[FlashList] checkIfAtBottom: Cannot determine position - assuming not at bottom'); ++ return false; ++ }, [isAtBottom]); ++ ++ /** ++ * Unified function to attempt scrolling to bottom ++ * Only scrolls if conditions are met (user hasn't scrolled away, should render from bottom, etc.) ++ * Checks if already at bottom before attempting to scroll (except for very first attempt) ++ */ ++ const attemptScrollToBottom = useCallback( ++ (reason: string, useInteractionManager: boolean = false, isFirstAttempt: boolean = false) => { ++ if (!shouldRenderFromBottom) { ++ console.log('[FlashList] attemptScrollToBottom: Skipped - shouldRenderFromBottom is false'); ++ return; ++ } ++ ++ if (isScrolledByUserRef.current) { ++ console.log('[FlashList] attemptScrollToBottom: Skipped - user has manually scrolled away'); ++ return; ++ } ++ ++ if (isUserTouchingRef.current) { ++ console.log('[FlashList] attemptScrollToBottom: Skipped - user is actively touching/scrolling'); ++ return; ++ } ++ ++ if (!handlerMethods) { ++ console.log('[FlashList] attemptScrollToBottom: Skipped - handlerMethods not available'); ++ return; ++ } ++ ++ if (pendingChildIds.size > 0) { ++ console.log('[FlashList] attemptScrollToBottom: Skipped - pending layout measurements'); ++ return; ++ } ++ ++ // Skip bottom check for very first scroll attempt (we know we're starting from top) ++ if (!isFirstAttempt) { ++ const alreadyAtBottom = checkIfAtBottom(); ++ if (alreadyAtBottom) { ++ console.log(`[FlashList] attemptScrollToBottom: Skipped - already at bottom (reason: ${reason})`); ++ return; ++ } ++ } else { ++ console.log('[FlashList] attemptScrollToBottom: First attempt - skipping bottom check'); ++ isVeryFirstScrollAttemptRef.current = false; ++ } ++ ++ console.log(`[FlashList] attemptScrollToBottom: Attempting to scroll to bottom (reason: ${reason}, isFirstAttempt: ${isFirstAttempt})`); ++ ++ const scrollFunction = () => { ++ if (handlerMethods && !isScrolledByUserRef.current && !isUserTouchingRef.current) { ++ // For first attempt, skip double-check since we know we're not at bottom ++ // For subsequent attempts, double-check we're still not at bottom ++ if (isFirstAttempt) { ++ handlerMethods.scrollToEnd({animated: true}); ++ scrollToBottomAttemptsRef.current += 1; ++ console.log(`[FlashList] attemptScrollToBottom: Scroll attempt #${scrollToBottomAttemptsRef.current} executed (first attempt)`); ++ } else { ++ const stillNotAtBottom = !checkIfAtBottom(); ++ if (stillNotAtBottom) { ++ handlerMethods.scrollToEnd({ animated: true }); ++ scrollToBottomAttemptsRef.current += 1; ++ console.log(`[FlashList] attemptScrollToBottom: Scroll attempt #${scrollToBottomAttemptsRef.current} executed`); ++ } else { ++ console.log('[FlashList] attemptScrollToBottom: Skipped - reached bottom before scroll execution'); ++ } ++ } ++ } ++ }; ++ ++ if (isFirstAttempt) { ++ // For first attempt, use single RAF for fastest scroll (reduces flicker) ++ requestAnimationFrame(scrollFunction); ++ } else if (useInteractionManager) { ++ InteractionManager.runAfterInteractions(() => { ++ requestAnimationFrame(() => { ++ requestAnimationFrame(scrollFunction); ++ }); ++ }); ++ } else { ++ requestAnimationFrame(() => { ++ requestAnimationFrame(scrollFunction); ++ }); ++ } ++ }, ++ [shouldRenderFromBottom, handlerMethods, pendingChildIds, checkIfAtBottom] ++ ); ++ + /** + * Scroll event handler that manages scroll position, velocity, and RTL support + */ +@@ -251,6 +542,49 @@ const RecyclerViewComponent = ( + ); + } + ++ // Track viewport size, content size, and scroll offset for bottom detection ++ const viewportSize = horizontal ++ ? event.nativeEvent.layoutMeasurement.width ++ : event.nativeEvent.layoutMeasurement.height; ++ const contentSize = horizontal ++ ? event.nativeEvent.contentSize.width ++ : event.nativeEvent.contentSize.height; ++ ++ lastViewportSizeRef.current = viewportSize; ++ lastScrollOffsetRef.current = scrollOffset; ++ ++ // Update content height ref for bottom detection ++ if (contentSize > 0) { ++ lastContentHeightRef.current = contentSize; ++ } ++ ++ // Detect user scroll state changes ONLY when user is actively touching ++ if (isUserTouchingRef.current) { ++ const atBottom = isAtBottom(scrollOffset, contentSize, viewportSize); ++ const wasScrolledByUser = isScrolledByUserRef.current; ++ ++ // Update scroll state based on position ++ if (!atBottom && !wasScrolledByUser) { ++ // User scrolled away from bottom ++ isScrolledByUserRef.current = true; ++ console.log('[FlashList] User scroll state: NOT scrolled by user → Scrolled by user'); ++ console.log(` - Scroll offset: ${scrollOffset.toFixed(2)}px`); ++ console.log(` - Content size: ${contentSize.toFixed(2)}px`); ++ console.log(` - Viewport size: ${viewportSize.toFixed(2)}px`); ++ console.log(` - At bottom: false`); ++ console.log(` - User touching: true`); ++ } else if (atBottom && wasScrolledByUser) { ++ // User scrolled back to bottom ++ isScrolledByUserRef.current = false; ++ console.log('[FlashList] User scroll state: Scrolled by user → NOT scrolled by user'); ++ console.log(` - Scroll offset: ${scrollOffset.toFixed(2)}px`); ++ console.log(` - Content size: ${contentSize.toFixed(2)}px`); ++ console.log(` - Viewport size: ${viewportSize.toFixed(2)}px`); ++ console.log(` - At bottom: true`); ++ console.log(` - User touching: true`); ++ } ++ } ++ + velocityTracker.computeVelocity( + scrollOffset, + recyclerViewManager.getAbsoluteLastScrollOffset(), +@@ -290,11 +624,38 @@ const RecyclerViewComponent = ( + computeFirstVisibleIndexForOffsetCorrection, + horizontal, + isHorizontalRTL, ++ isAtBottom, + recyclerViewManager, + velocityTracker, + ] + ); + ++ /** ++ * Handler for when user starts dragging/touching the list ++ */ ++ const onScrollBeginDragHandler = useCallback( ++ (event: NativeSyntheticEvent) => { ++ isUserTouchingRef.current = true; ++ console.log('[FlashList] User started touching the list'); ++ // Call user-provided handler if exists ++ recyclerViewManager.props.onScrollBeginDrag?.(event); ++ }, ++ [recyclerViewManager] ++ ); ++ ++ /** ++ * Handler for when user stops dragging/touching the list ++ */ ++ const onScrollEndDragHandler = useCallback( ++ (event: NativeSyntheticEvent) => { ++ isUserTouchingRef.current = false; ++ console.log('[FlashList] User stopped touching the list'); ++ // Call user-provided handler if exists ++ recyclerViewManager.props.onScrollEndDrag?.(event); ++ }, ++ [recyclerViewManager] ++ ); ++ + const parentRecyclerViewContext = useRecyclerViewContext(); + const recyclerViewId = useId(); + +@@ -435,18 +796,78 @@ const RecyclerViewComponent = ( + recyclerViewManager.shouldMaintainVisibleContentPosition(); + + const maintainVisibleContentPositionInternal = useMemo(() => { +- if (shouldMaintainVisibleContentPosition) { +- return { ++ if (shouldMaintainVisibleContentPosition && maintainVisibleContentPosition) { ++ const config = { + ...maintainVisibleContentPosition, +- minIndexForVisible: 0, +- }; ++ } as typeof maintainVisibleContentPosition & { minIndexForVisible?: number }; ++ // For inverted lists starting from bottom, don't set minIndexForVisible ++ // as it interferes with maintaining position of items at the bottom ++ if (!maintainVisibleContentPosition.startRenderingFromBottom) { ++ config.minIndexForVisible = 0; ++ } ++ return config; + } + return undefined; + }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]); + +- const shouldRenderFromBottom = +- recyclerViewManager.getDataLength() > 0 && +- (maintainVisibleContentPosition?.startRenderingFromBottom ?? false); ++ // Handle onContentSizeChange to ensure we scroll to bottom on first render ++ // and when data changes significantly (e.g., from 1 to 50 messages) ++ const handleContentSizeChange = useCallback( ++ (contentWidth: number, contentHeight: number) => { ++ console.log( ++ `[FlashList] handleContentSizeChange: contentHeight=${contentHeight.toFixed(2)}, previous=${lastContentHeightRef.current.toFixed(2)}` ++ ); ++ ++ const currentDataLength = recyclerViewManager.getDataLength(); ++ const previousContentHeight = lastContentHeightRef.current; ++ ++ // Check if this is a significant data change (e.g., 1 message -> 50 messages) ++ // This handles the case where initial DB query returns 1 message, then sync loads 50 ++ const isSignificantDataChange = ++ lastDataLengthRef.current > 0 && ++ currentDataLength > lastDataLengthRef.current && ++ (currentDataLength - lastDataLengthRef.current) >= 10; ++ ++ const isInitialScroll = !hasInitialScrolledToBottomRef.current; ++ ++ if (isSignificantDataChange) { ++ console.log( ++ `[FlashList] handleContentSizeChange: Significant data change detected (${lastDataLengthRef.current} -> ${currentDataLength} items)` ++ ); ++ hasInitialScrolledToBottomRef.current = false; ++ scrollToBottomAttemptsRef.current = 0; // Reset attempt counter ++ isVeryFirstScrollAttemptRef.current = true; // Reset first attempt flag ++ } ++ ++ // Update tracked values ++ lastDataLengthRef.current = currentDataLength; ++ lastContentHeightRef.current = contentHeight; ++ ++ if ( ++ shouldRenderFromBottom && ++ isInitialScroll && ++ currentDataLength > 0 && ++ contentHeight > 0 ++ ) { ++ console.log('[FlashList] handleContentSizeChange: Conditions met, attempting scroll to bottom (initial)'); ++ ++ // Mark as attempted before scrolling to prevent multiple attempts ++ hasInitialScrolledToBottomRef.current = true; ++ ++ // For initial scroll, scroll to bottom immediately without animation ++ // Use synchronous scroll to prevent flickering ++ if (!isScrolledByUserRef.current && !isUserTouchingRef.current && handlerMethods) { ++ handlerMethods.scrollToEnd({ animated: false }); ++ } ++ } ++ }, ++ [ ++ shouldRenderFromBottom, ++ recyclerViewManager, ++ handlerMethods, ++ attemptScrollToBottom, ++ ] ++ ); + + // Create view for measuring bounded size + const viewToMeasureBoundedSize = useMemo(() => { +@@ -512,6 +933,9 @@ const RecyclerViewComponent = ( + horizontal={horizontal} + ref={scrollViewRef} + onScroll={animatedEvent} ++ onScrollBeginDrag={onScrollBeginDragHandler} ++ onScrollEndDrag={onScrollEndDragHandler} ++ onContentSizeChange={handleContentSizeChange} + maintainVisibleContentPosition={ + maintainVisibleContentPositionInternal + } +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx +index 4d99406..93e962a 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx +@@ -52,6 +52,7 @@ export function useRecyclerViewController( + const [_, setRenderId] = useState(0); + const pauseOffsetCorrection = useRef(false); + const initialScrollCompletedRef = useRef(false); ++ const initialScrollToBottomAttemptedRef = useRef(false); + const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); + const { setTimeout } = useUnmountAwareTimeout(); + +@@ -569,6 +570,26 @@ export function useRecyclerViewController( + const initialScrollIndex = + recyclerViewManager.getInitialScrollIndex() ?? -1; + const dataLength = data?.length ?? 0; ++ const shouldRenderFromBottom = ++ recyclerViewManager.props.maintainVisibleContentPosition?.startRenderingFromBottom ?? false; ++ ++ // Detect if data length changed significantly (e.g., 1 -> 50 items) ++ // If so, reset initial scroll state to allow scrolling to new bottom ++ const previousDataLength = lastDataLengthRef.current; ++ const isSignificantDataChange = ++ previousDataLength > 0 && ++ dataLength > previousDataLength && ++ (dataLength - previousDataLength) >= 5; ++ ++ if (isSignificantDataChange && shouldRenderFromBottom) { ++ console.log( ++ `[FlashList] applyInitialScrollIndex: Significant data change detected (${previousDataLength} -> ${dataLength}), resetting initial scroll state` ++ ); ++ // Reset to allow scrolling to new bottom ++ initialScrollCompletedRef.current = false; ++ initialScrollToBottomAttemptedRef.current = false; ++ } ++ + if ( + initialScrollIndex >= 0 && + initialScrollIndex < dataLength && +@@ -592,14 +613,34 @@ export function useRecyclerViewController( + skipFirstItemOffset: false, + }); + +- setTimeout(() => { +- handlerMethods.scrollToOffset({ +- offset, +- animated: false, +- skipFirstItemOffset: false, +- }); +- }, 0); ++ // For startRenderingFromBottom, ensure we scroll to actual bottom after layout ++ // This handles variable height items where initial scroll index position might not be accurate ++ if (shouldRenderFromBottom && dataLength > 0 && !initialScrollToBottomAttemptedRef.current) { ++ initialScrollToBottomAttemptedRef.current = true; ++ // Scroll immediately after the first offset scroll to minimize flickering ++ setTimeout(() => { ++ handlerMethods.scrollToOffset({ ++ offset, ++ animated: false, ++ skipFirstItemOffset: false, ++ }); ++ ++ // Scroll to end immediately after to ensure we're at the actual bottom ++ handlerMethods.scrollToEnd({ animated: false }); ++ }, 0); ++ } else { ++ setTimeout(() => { ++ handlerMethods.scrollToOffset({ ++ offset, ++ animated: false, ++ skipFirstItemOffset: false, ++ }); ++ }, 0); ++ } + } ++ ++ // Update last data length for next comparison ++ lastDataLengthRef.current = dataLength; + }, [handlerMethods, recyclerViewManager, setTimeout]); + + // Expose imperative methods through the ref diff --git a/yarn.lock b/yarn.lock index 5f334cbad37..b6302e1da52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4391,7 +4391,7 @@ resolved "https://registry.yarnpkg.com/@react-native-masked-view/masked-view/-/masked-view-0.3.1.tgz#5bd76f17004a6ccbcec03856893777ee91f23d29" integrity sha512-uVm8U6nwFIlUd1iDIB5cS+lDadApKR+l8k4k84d9hn+GN4lzAIJhUZ9syYX7c022MxNgAlbxoFLt0pqKoyaAGg== -"@react-native-picker/picker@^2.11.0": +"@react-native-picker/picker@2.11.0": version "2.11.0" resolved "https://registry.yarnpkg.com/@react-native-picker/picker/-/picker-2.11.0.tgz#4587fbce6a382adedad74311e96ee10bb2b2d63a" integrity sha512-QuZU6gbxmOID5zZgd/H90NgBnbJ3VV6qVzp6c7/dDrmWdX8S0X5YFYgDcQFjE3dRen9wB9FWnj2VVdPU64adSg== @@ -5042,6 +5042,13 @@ 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.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.0.3.tgz#222427d1e09bf5cdd8a219d0a5a80f6f1d20465d" + integrity sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA== + dependencies: + tslib "2.8.1" + "@sideway/address@^4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" @@ -14586,6 +14593,11 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"