diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch index 007d0682e1cc2..e9d9e3dfd9810 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -index fb40ded..ea4eba2 100644 +index fb40ded..12375d9 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js @@ -92,6 +92,17 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch new file mode 100644 index 0000000000000..edb436a356b46 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch @@ -0,0 +1,191 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index dd2d3bc..d7a3d84 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -2,8 +2,8 @@ + * RecyclerView is a high-performance list component that efficiently renders and recycles list items. + * It's designed to handle large lists with optimal memory usage and smooth scrolling. + */ +-import React, { useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; +-import { Animated, I18nManager, } from "react-native"; ++import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; ++import { Animated, I18nManager, Platform, } from "react-native"; + import { ErrorMessages } from "../errors/ErrorMessages"; + import { WarningMessages } from "../errors/WarningMessages"; + import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; +@@ -66,6 +66,66 @@ const RecyclerViewComponent = (props, ref) => { + // Hook to detect when scrolling reaches list bounds + const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); + const isHorizontalRTL = I18nManager.isRTL && horizontal; ++ // Web-only: Fix inverted scroll direction. ++ useEffect(() => { ++ if (!inverted || Platform.OS !== "web") { ++ return; ++ } ++ const scrollRef = scrollViewRef.current; ++ if (!scrollRef || typeof scrollRef.getScrollableNode !== "function") { ++ return; ++ } ++ const node = scrollRef.getScrollableNode(); ++ if (!node) { ++ return; ++ } ++ const wheelHandler = (ev) => { ++ const target = ev.target; ++ const deltaX = ev.deltaX || ev.wheelDeltaX || 0; ++ const deltaY = ev.deltaY || ev.wheelDeltaY || 0; ++ // Compute scroll limits from the DOM node for overscroll recoil prevention. ++ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; ++ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; ++ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; ++ const isOnScrollLimit = nodeScrollOffset <= 0 || Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; ++ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; ++ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; ++ const clientLength = horizontal ? target.clientWidth : target.clientHeight; ++ const isEventTargetScrollable = scrollLength > clientLength; ++ const delta = horizontal ? deltaX : deltaY; ++ let leftoverDelta = delta; ++ if (isEventTargetScrollable) { ++ leftoverDelta = delta < 0 ++ ? Math.min(delta + scrollOffset, 0) ++ : Math.max(delta - (scrollLength - clientLength - scrollOffset), 0); ++ } ++ const targetDelta = delta - leftoverDelta; ++ if (horizontal) { ++ if (Math.abs(deltaX) > Math.abs(deltaY)) { ++ target.scrollLeft += targetDelta; ++ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } ++ } ++ else { ++ // Prevent overscroll recoil/rubber band at scroll boundaries. ++ if (isOnScrollLimit && Math.abs(deltaY) > 0) { ++ ev.preventDefault(); ++ } ++ if (Math.abs(deltaY) > Math.abs(deltaX)) { ++ target.scrollTop += targetDelta; ++ node.scrollTop = node.scrollTop - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } ++ } ++ }; ++ node.addEventListener("wheel", wheelHandler, { passive: false }); ++ return () => { ++ node.removeEventListener("wheel", wheelHandler); ++ }; ++ }, [inverted, horizontal]); + /** + * Initialize the RecyclerView by measuring and setting up the window size + * This effect runs when the component mounts or when layout changes +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 34722d4..ea801d2 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -5,6 +5,7 @@ + import React, { + RefObject, + useCallback, ++ useEffect, + useLayoutEffect, + useMemo, + useRef, +@@ -17,6 +18,7 @@ import { + I18nManager, + NativeScrollEvent, + NativeSyntheticEvent, ++ Platform, + } from "react-native"; + + import { FlashListRef } from "../FlashListRef"; +@@ -158,6 +160,88 @@ const RecyclerViewComponent = ( + + const isHorizontalRTL = I18nManager.isRTL && horizontal; + ++ /** ++ * Web-only: Fix inverted scroll direction. ++ * When a list is visually inverted via scaleY/scaleX: -1, the browser's native ++ * wheel scroll goes in the wrong visual direction. This effect attaches a wheel ++ * event listener that negates the delta to correct the scroll direction. ++ * Mirrors the fix in react-native-web's VirtualizedList. ++ */ ++ useEffect(() => { ++ if (!inverted || Platform.OS !== "web") { ++ return; ++ } ++ const scrollRef = scrollViewRef.current; ++ if (!scrollRef || typeof (scrollRef as any).getScrollableNode !== "function") { ++ return; ++ } ++ const node = (scrollRef as any).getScrollableNode() as HTMLElement; ++ if (!node) { ++ return; ++ } ++ ++ const wheelHandler = (ev: WheelEvent) => { ++ const target = ev.target as HTMLElement; ++ const deltaX = ev.deltaX || (ev as any).wheelDeltaX || 0; ++ const deltaY = ev.deltaY || (ev as any).wheelDeltaY || 0; ++ ++ // Compute scroll limits from the DOM node for overscroll recoil prevention. ++ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; ++ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; ++ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; ++ const isOnScrollLimit = ++ nodeScrollOffset <= 0 || ++ Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; ++ ++ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; ++ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; ++ const clientLength = horizontal ? target.clientWidth : target.clientHeight; ++ const isEventTargetScrollable = scrollLength > clientLength; ++ const delta = horizontal ? deltaX : deltaY; ++ ++ // Calculate how much delta the event target can consume vs leftover for parent ++ let leftoverDelta = delta; ++ if (isEventTargetScrollable) { ++ leftoverDelta = ++ delta < 0 ++ ? Math.min(delta + scrollOffset, 0) ++ : Math.max( ++ delta - (scrollLength - clientLength - scrollOffset), ++ 0 ++ ); ++ } ++ const targetDelta = delta - leftoverDelta; ++ ++ // Only adjust scroll and consume the event when the dominant axis ++ // matches the list orientation. stopPropagation prevents parent ++ // inverted lists from also handling this event. ++ if (horizontal) { ++ if (Math.abs(deltaX) > Math.abs(deltaY)) { ++ target.scrollLeft += targetDelta; ++ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } ++ } else { ++ // Prevent overscroll recoil/rubber band at scroll boundaries. ++ if (isOnScrollLimit && Math.abs(deltaY) > 0) { ++ ev.preventDefault(); ++ } ++ if (Math.abs(deltaY) > Math.abs(deltaX)) { ++ target.scrollTop += targetDelta; ++ node.scrollTop = node.scrollTop - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } ++ } ++ }; ++ ++ node.addEventListener("wheel", wheelHandler, { passive: false }); ++ return () => { ++ node.removeEventListener("wheel", wheelHandler); ++ }; ++ }, [inverted, horizontal]); ++ + /** + * Initialize the RecyclerView by measuring and setting up the window size + * This effect runs when the component mounts or when layout changes diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch new file mode 100644 index 0000000000000..98bac124be640 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index d7a3d84..ffcdad8 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -142,9 +142,11 @@ const RecyclerViewComponent = (props, ref) => { + containerViewSizeRef.current = outerViewSize; + // firstChildViewLayout is already relative to the outer container, + // so its x/y directly gives the first item offset. +- const firstItemOffset = horizontal +- ? firstChildViewLayout.x +- : firstChildViewLayout.y; ++ const firstItemOffset = inverted ++ ? 0 ++ : horizontal ++ ? firstChildViewLayout.x ++ : firstChildViewLayout.y; + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams({ + width: horizontal ? outerViewSize.width : firstChildViewLayout.width, +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index ea801d2..8a7deff 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -259,9 +259,11 @@ const RecyclerViewComponent = ( + + // firstChildViewLayout is already relative to the outer container, + // so its x/y directly gives the first item offset. +- const firstItemOffset = horizontal +- ? firstChildViewLayout.x +- : firstChildViewLayout.y; ++ const firstItemOffset = inverted ++ ? 0 ++ : horizontal ++ ? firstChildViewLayout.x ++ : firstChildViewLayout.y; + + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams( diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch new file mode 100644 index 0000000000000..6b96845c5875e --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch @@ -0,0 +1,68 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index ffcdad8..ee42f63 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -166,9 +166,6 @@ const RecyclerViewComponent = (props, ref) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + var _a, _b; +- if (pendingChildIds.size > 0) { +- return; +- } + if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 && + ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) { + return; +@@ -196,8 +193,17 @@ const RecyclerViewComponent = (props, ref) => { + } + if (recyclerViewManager.modifyChildrenLayout(layoutInfo, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) && + !hasExceededMaxRendersWithoutCommit) { +- // Trigger re-render if layout modifications were made +- setRenderId((prev) => prev + 1); ++ if (pendingChildIds.size > 0) { ++ // When child FlashLists are still loading, avoid triggering a full ++ // RecyclerView re-render (setRenderId) to prevent cascading setState ++ // calls that could cause "Maximum update depth exceeded" errors. ++ // Instead, just commit the layout to update item positions in ++ // ViewHolderCollection without re-measuring. ++ (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); ++ } else { ++ // Trigger re-render if layout modifications were made ++ setRenderId((prev) => prev + 1); ++ } + } + else { + (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 8a7deff..b2bd67a 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -287,9 +287,6 @@ const RecyclerViewComponent = ( + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { +- if (pendingChildIds.size > 0) { +- return; +- } + const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { + const layout = measureItemLayout( + viewHolderRef.current!, +@@ -323,8 +320,17 @@ const RecyclerViewComponent = ( + recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) && + !hasExceededMaxRendersWithoutCommit + ) { +- // Trigger re-render if layout modifications were made +- setRenderId((prev) => prev + 1); ++ if (pendingChildIds.size > 0) { ++ // When child FlashLists are still loading, avoid triggering a full ++ // RecyclerView re-render (setRenderId) to prevent cascading setState ++ // calls that could cause "Maximum update depth exceeded" errors. ++ // Instead, just commit the layout to update item positions in ++ // ViewHolderCollection without re-measuring. ++ viewHolderCollectionRef.current?.commitLayout(); ++ } else { ++ // Trigger re-render if layout modifications were made ++ setRenderId((prev) => prev + 1); ++ } + } else { + viewHolderCollectionRef.current?.commitLayout(); + applyOffsetCorrection(); diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 6012a1c00a87c..e8c88d64093ec 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -20,3 +20,24 @@ - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/83976 - PR introducing patch: https://github.com/Expensify/App/pull/84887 + +### [@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch) + +- Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly — scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD + +### [@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch) + +- Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system — causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD + +### [@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch) + +- Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` — while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD diff --git a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx new file mode 100644 index 0000000000000..65397e70a570f --- /dev/null +++ b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; +import {View} from 'react-native'; + +type CellRendererComponentProps = ViewProps & { + index: number; + style?: StyleProp; +}; + +function CellRendererComponent(props: CellRendererComponentProps) { + return ( + + ); +} + +export default CellRendererComponent; diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx new file mode 100644 index 0000000000000..942981f341ce9 --- /dev/null +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -0,0 +1,33 @@ +import type {FlashListProps} from '@shopify/flash-list'; +import React from 'react'; +import useFlashListScrollKey from '@components/FlashList/useFlashListScrollKey'; +import FlashList from '..'; +import CellRendererComponent from './CellRendererComponent'; + +type InvertedFlashListProps = FlashListProps & { + initialScrollKey?: string | null; + data: T[]; + keyExtractor: (item: T, index: number) => string; + shouldHideContent?: boolean; +}; + +function InvertedFlashList({data, keyExtractor, initialScrollKey, ...restProps}: InvertedFlashListProps) { + const {displayedData} = useFlashListScrollKey({ + data, + keyExtractor, + initialScrollKey, + }); + + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading + {...restProps} + inverted + data={displayedData} + keyExtractor={keyExtractor} + CellRendererComponent={CellRendererComponent} + /> + ); +} + +export default InvertedFlashList; diff --git a/src/components/FlashList/index.native.tsx b/src/components/FlashList/index.native.tsx new file mode 100644 index 0000000000000..a8a4ec93a9034 --- /dev/null +++ b/src/components/FlashList/index.native.tsx @@ -0,0 +1,36 @@ +import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; +import type {FlashListProps} from '@shopify/flash-list'; +import React, {useCallback} from 'react'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type CustomFlashListProps = FlashListProps & { + shouldHideContent?: boolean; +}; + +function FlashList({onScroll: onScrollProp, inverted, shouldHideContent = false, contentContainerStyle, ...restProps}: CustomFlashListProps) { + const styles = useThemeStyles(); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + + const handleScroll = useCallback( + (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll + emitComposerScrollEvents(); + }, + [emitComposerScrollEvents, onScrollProp], + ); + + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading + {...restProps} + inverted={inverted} + onScroll={handleScroll} + contentContainerStyle={shouldHideContent ? [contentContainerStyle, shouldHideContent && styles.opacity0] : contentContainerStyle} + /> + ); +} + +export default FlashList; diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx new file mode 100644 index 0000000000000..305abb442c759 --- /dev/null +++ b/src/components/FlashList/index.tsx @@ -0,0 +1,36 @@ +import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; +import type {FlashListProps} from '@shopify/flash-list'; +import React, {useCallback} from 'react'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type CustomFlashListProps = FlashListProps & { + shouldHideContent?: boolean; +}; + +function FlashList({onScroll: onScrollProp, inverted, shouldHideContent = false, contentContainerStyle, ...restProps}: CustomFlashListProps) { + const styles = useThemeStyles(); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + + const handleScroll = useCallback( + (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll + emitComposerScrollEvents(); + }, + [emitComposerScrollEvents, onScrollProp], + ); + + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading + {...restProps} + inverted={inverted} + onScroll={handleScroll} + contentContainerStyle={shouldHideContent ? [contentContainerStyle, styles.visibilityHidden] : contentContainerStyle} + /> + ); +} + +export default FlashList; diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts new file mode 100644 index 0000000000000..3ba3fc5999020 --- /dev/null +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -0,0 +1,33 @@ +import {useEffect, useState} from 'react'; + +type FlashListScrollKeyProps = { + data: T[]; + keyExtractor: (item: T, index: number) => string; + initialScrollKey: string | null | undefined; +}; + +export default function useFlashListScrollKey({data, keyExtractor, initialScrollKey}: FlashListScrollKeyProps) { + const [isInitialRender, setIsInitialRender] = useState(true); + + // After the first render with sliced data, give FlashList one frame to lay out, + // then switch to the full data array. maintainVisibleContentPosition keeps the target pinned. + useEffect(() => { + if (!isInitialRender || !initialScrollKey) { + return; + } + requestAnimationFrame(() => setIsInitialRender(false)); + }, [isInitialRender, initialScrollKey]); + + if (!isInitialRender || !initialScrollKey) { + return {displayedData: data}; + } + + const targetIndex = data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey); + if (targetIndex <= 0) { + return {displayedData: data}; + } + + // On the first render, slice from the target onward so the target item + // appears at the visual bottom of the inverted list — no scrolling needed. + return {displayedData: data.slice(targetIndex)}; +} diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 16eb943f63a4f..2ae0650184ba6 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,15 +1,15 @@ -import type {ListRenderItemInfo} from '@react-native/virtualized-lists'; import {useIsFocused, useRoute} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import {tierNameSelector} from '@selectors/UserWallet'; +import type {ListRenderItemInfo} from '@shopify/flash-list'; import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import Button from '@components/Button'; +import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; -import InvertedFlatList from '@components/FlatList/InvertedFlatList'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -119,9 +119,6 @@ type ReportActionsListProps = { /** ID of the list */ listID: number; - /** Should enable auto scroll to top threshold */ - shouldEnableAutoScrollToTopThreshold?: boolean; - /** Whether the optimistic CREATED report action was added */ hasCreatedActionAdded?: boolean; @@ -165,8 +162,6 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { return item.reportActionID; } -const onScrollToIndexFailed = () => {}; - function ReportActionsList({ report, transactionThreadReport, @@ -180,7 +175,6 @@ function ReportActionsList({ onLayout, isComposerFullSize, listID, - shouldEnableAutoScrollToTopThreshold, parentReportActionForTransactionThread, hasCreatedActionAdded, isConciergeSidePanel, @@ -229,14 +223,13 @@ function ReportActionsList({ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); const backTo = route?.params?.backTo as string; - // Display the new message indicator when comment linking and not close to the newest message. - const reportActionID = route?.params?.reportActionID; + const linkedReportActionID = route?.params?.reportActionID; const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); const topReportAction = sortedVisibleReportActions.at(-1); - const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !reportActionID); + const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); const isAnonymousUser = useIsAnonymousUser(); useEffect(() => { @@ -249,7 +242,6 @@ function ReportActionsList({ const readActionSkipped = useRef(false); const hasHeaderRendered = useRef(false); - const linkedReportActionID = route?.params?.reportActionID; const lastAction = sortedVisibleReportActions.at(0); const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( @@ -909,20 +901,20 @@ function ReportActionsList({ fsClass={reportActionsListFSClass} > {shouldScrollToEndAfterLayout && topReportAction ? renderTopReportActions() : undefined} - { trackVerticalScrolling(undefined); }} diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 605caa66ef4b2..ef1814c8ec3a2 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -1,6 +1,5 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {InteractionManager} from 'react-native'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; @@ -142,7 +141,6 @@ function ReportActionsView({ const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const prevTransactionThreadReport = usePrevious(transactionThreadReport); const reportActionID = route?.params?.reportActionID; const prevReportActionID = usePrevious(reportActionID); const reportPreviewAction = useMemo(() => getReportPreviewAction(report.chatReportID, report.reportID), [report.chatReportID, report.reportID]); @@ -151,7 +149,6 @@ function ReportActionsView({ const {shouldUseNarrowLayout} = useResponsiveLayout(); const isFocused = useIsFocused(); - const [isNavigatingToLinkedMessage, setNavigatingToLinkedMessage] = useState(false); const prevShouldUseNarrowLayoutRef = useRef(shouldUseNarrowLayout); const reportID = report.reportID; const isReportFullyVisible = useMemo((): boolean => getIsReportFullyVisible(isFocused), [isFocused]); @@ -281,12 +278,7 @@ function ReportActionsView({ [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, visibleReportActionsData, reportID], ); - const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); const mostRecentIOUReportActionID = useMemo(() => getMostRecentIOURequestActionID(reportActions), [reportActions]); - const lastActionCreated = visibleReportActions.at(0)?.created; - const isNewestAction = (actionCreated: string | undefined, lastVisibleActionCreated: string | undefined) => - actionCreated && lastVisibleActionCreated ? actionCreated >= lastVisibleActionCreated : actionCreated === lastVisibleActionCreated; - const hasNewestReportAction = isNewestAction(lastActionCreated, report.lastVisibleActionCreated) || isNewestAction(lastActionCreated, transactionThreadReport?.lastVisibleActionCreated); const isSingleExpenseReport = reportPreviewAction?.childMoneyRequestCount === 1; const isMissingTransactionThreadReportID = !transactionThreadReport?.reportID; @@ -350,33 +342,6 @@ function ReportActionsView({ [report, onLayout], ); - // Check if the first report action in the list is the one we're currently linked to - const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID; - - useEffect(() => { - let timerID: NodeJS.Timeout; - - if (!isTheFirstReportActionIsLinked && reportActionID) { - setNavigatingToLinkedMessage(true); - // After navigating to the linked reportAction, apply this to correctly set - // `autoscrollToTopThreshold` prop when linking to a specific reportAction. - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - // Using a short delay to ensure the view is updated after interactions - timerID = setTimeout(() => setNavigatingToLinkedMessage(false), 10); - }); - } else { - setNavigatingToLinkedMessage(false); - } - - return () => { - if (!timerID) { - return; - } - clearTimeout(timerID); - }; - }, [isTheFirstReportActionIsLinked, reportActionID]); - // Show skeleton while loading initial report actions when data is incomplete/missing and online const shouldShowSkeletonForInitialLoad = isLoadingInitialReportActions && (isReportDataIncomplete || isMissingReportActions) && !isOffline; @@ -408,8 +373,6 @@ function ReportActionsView({ return ; } - // AutoScroll is disabled when we do linking to a specific reportAction - const shouldEnableAutoScroll = (hasNewestReportAction && (!reportActionID || !isNavigatingToLinkedMessage)) || (transactionThreadReport && !prevTransactionThreadReport); return ( <>