Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d63c643
add useUnreadMarker hook
adhorodyski Apr 8, 2026
01452ef
add useTransactionThread hook
adhorodyski Apr 8, 2026
fad2679
add useMarkAsRead hook
adhorodyski Apr 8, 2026
0270f06
add useReportActionsPagination, useReportActionsVisibility, modify us…
adhorodyski Apr 8, 2026
1a65989
add useReportActionsScroll hook
adhorodyski Apr 8, 2026
ebd5cb9
add ReportActionsListHeader and ShowPreviousMessagesButton
adhorodyski Apr 8, 2026
e6f2414
rewrite ReportActionsList, wire router, delete old files
adhorodyski Apr 8, 2026
c91a786
Merge remote-tracking branch 'exfy/main' into decompose/report-action…
adhorodyski Apr 8, 2026
1727f9b
inline renderTopReportActions as JSX
adhorodyski Apr 8, 2026
6b459b9
stop passing isOffline through visibility hook, use useNetwork directly
adhorodyski Apr 8, 2026
f4a2cce
use useSyncExternalStore for visibility in useMarkAsRead
adhorodyski Apr 9, 2026
6d6bb6b
simplify list key id
adhorodyski Apr 9, 2026
5befd1b
clean up renderItem section comments
adhorodyski Apr 9, 2026
21a441a
strip down dead code
adhorodyski Apr 9, 2026
467f18d
document shouldScrollToEndAfterLayout workaround and initialNumToRend…
adhorodyski Apr 9, 2026
f50e39b
use object params for useReportActionsVisibility
adhorodyski Apr 9, 2026
c8a611f
fix timing skeleton by exposing transaction loading state
adhorodyski Apr 9, 2026
b555b3c
destructure visibility hook, rename hideComposer to isWriteActionDisa…
adhorodyski Apr 9, 2026
a237e71
Merge remote-tracking branch 'exfy/main' into decompose/report-action…
adhorodyski Apr 9, 2026
753f1a8
remove plan file from branch
adhorodyski Apr 9, 2026
02cc8d0
deduplicate getFirstVisibleReportActionID call
adhorodyski Apr 9, 2026
99be7ad
fix backTo param, userActiveSince on unreadAction, shouldEnableAutoSc…
adhorodyski Apr 10, 2026
d87bf08
backTo reads from route directly, each hook owns its own event subscr…
adhorodyski Apr 10, 2026
c844111
simplify shouldEnableAutoScrollToTopThreshold to !hasNewerActions
adhorodyski Apr 10, 2026
e0409e9
merge exfy/main, migrate to InvertedFlashList
adhorodyski Apr 17, 2026
0df40a1
use startRenderingFromBottom, drop hidden-render+timer hack
adhorodyski Apr 17, 2026
fbd4042
delete unused StaticReportActionsPreview
adhorodyski Apr 17, 2026
d0d11d8
restore inverted wheel direction on web via InvertedFlashList wheel h…
adhorodyski Apr 17, 2026
9ebfffa
scope startRenderingFromBottom to mount-at-top, fix canPerformWriteAc…
adhorodyski Apr 17, 2026
0c31e4d
tidy hooks: drop dead returns, simplify paginator, remove redundant s…
adhorodyski Apr 17, 2026
2c82657
use useEffectEvent for Pusher callback, drop dead guards in useMarkAs…
adhorodyski Apr 17, 2026
db5ca87
destructure useReportActionsScroll result at call site
adhorodyski Apr 17, 2026
d14b48b
drop prevUnreadMarkerReportActionID — suppression now consistent rega…
adhorodyski Apr 17, 2026
3ad9d54
scope visible actions selector; compiler-clean pipeline hooks
adhorodyski Apr 17, 2026
1d04541
own list ref locally so ReportActionsList compiles
adhorodyski Apr 17, 2026
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
19 changes: 16 additions & 3 deletions src/components/FlashList/InvertedFlashList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type {FlashListProps} from '@shopify/flash-list';
import React from 'react';
import type {FlashListProps, FlashListRef} from '@shopify/flash-list';
import React, {useRef} from 'react';
import useFlashListScrollKey from '@components/FlashList/useFlashListScrollKey';
import type {FlatListRefType} from '@pages/inbox/ReportScreenContext';
import FlashList from '..';
import CellRendererComponent from './CellRendererComponent';
import useInvertedWheelHandler from './useInvertedWheelHandler';

type InvertedFlashListProps<T> = FlashListProps<T> & {
/** Key of the item to initially scroll to when the list first renders. */
Expand All @@ -19,19 +20,31 @@ type InvertedFlashListProps<T> = FlashListProps<T> & {
ref: FlatListRefType;
};

function InvertedFlashList<T>({data, keyExtractor, initialScrollKey, onStartReached: onStartReachedProp, ...restProps}: InvertedFlashListProps<T>) {
function InvertedFlashList<T>({ref, data, keyExtractor, initialScrollKey, onStartReached: onStartReachedProp, ...restProps}: InvertedFlashListProps<T>) {
const {displayedData, onStartReached} = useFlashListScrollKey<T>({
data,
keyExtractor,
initialScrollKey,
onStartReached: onStartReachedProp,
});

const innerRef = useRef<FlashListRef<T> | null>(null);
useInvertedWheelHandler(innerRef);

const composedRef = (node: FlashListRef<T> | null) => {
innerRef.current = node;
if (ref) {
const userRef = ref as unknown as React.MutableRefObject<FlashListRef<T> | null>;
userRef.current = node;
}
};

return (
<FlashList<T>
// eslint-disable-next-line react/jsx-props-no-spreading
{...restProps}
inverted
ref={composedRef}
onStartReached={onStartReached}
data={displayedData}
keyExtractor={keyExtractor}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type {FlashListRef} from '@shopify/flash-list';

// No-op on native. Inverted wheel handling is only needed on web where the
// inverted `scaleY: -1` transform reverses wheel direction.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function useInvertedWheelHandler<T>(ref: React.RefObject<FlashListRef<T> | null>) {}

export default useInvertedWheelHandler;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type {FlashListRef} from '@shopify/flash-list';
import {useEffect} from 'react';

// FlashList uses its own RecyclerView on web and bypasses react-native-web's
// `invertedWheelEventHandler` patch (VirtualizedList/index.js:702). That patch flips
// wheel delta to compensate for the `scaleY: -1` transform applied by `inverted`.
// Without it, wheel scroll feels reversed: wheel down reveals older messages
// instead of newer. This hook restores parity by intercepting wheel events on the
// scrollable node and translating them into an inverted scrollTop adjustment.
function useInvertedWheelHandler<T>(ref: React.RefObject<FlashListRef<T> | null>) {
useEffect(() => {
const node = ref.current?.getScrollableNode?.() as HTMLElement | undefined;
if (!node) {
return;
}

const handler = (ev: WheelEvent) => {
const deltaY = ev.deltaY;
if (!deltaY) {
return;
}

const maxScrollTop = Math.max(0, node.scrollHeight - node.clientHeight);
const nextScrollTop = node.scrollTop - deltaY;
node.scrollTop = Math.max(0, Math.min(nextScrollTop, maxScrollTop));

ev.preventDefault();
ev.stopPropagation();
};

node.addEventListener('wheel', handler, {passive: false});
return () => node.removeEventListener('wheel', handler);
}, [ref]);
}

export default useInvertedWheelHandler;
7 changes: 5 additions & 2 deletions src/components/FlashList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {FlashList as ShopifyFlashList} from '@shopify/flash-list';
import type {FlashListProps} from '@shopify/flash-list';
import type {FlashListProps, FlashListRef} from '@shopify/flash-list';
import React from 'react';
import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents';

function FlashList<T>({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps<T>) {
type FlashListPropsWithRef<T> = FlashListProps<T> & {ref?: React.Ref<FlashListRef<T>>};

function FlashList<T>({ref, onScroll: onScrollProp, inverted, ...restProps}: FlashListPropsWithRef<T>) {
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});

const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
Expand All @@ -17,6 +19,7 @@ function FlashList<T>({onScroll: onScrollProp, inverted, ...restProps}: FlashLis
<ShopifyFlashList<T>
// eslint-disable-next-line react/jsx-props-no-spreading
{...restProps}
ref={ref}
inverted={inverted}
onScroll={handleScroll}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {isUserValidatedSelector} from '@selectors/Account';
import {tierNameSelector} from '@selectors/UserWallet';
import isEmpty from 'lodash/isEmpty';
import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import type {LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
import type {FlatList, LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
import {DeviceEventEmitter, InteractionManager, View} from 'react-native';
import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey';
import {usePersonalDetails} from '@components/OnyxListItemProvider';
Expand Down Expand Up @@ -205,11 +205,16 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)

const lastAction = visibleReportActions.at(-1);

const {scrollOffsetRef} = useContext(ActionListContext);
const {scrollOffsetRef, registerListRef} = useContext(ActionListContext);
const scrollingVerticalBottomOffset = useRef(0);
const scrollingVerticalTopOffset = useRef(0);
const wrapperViewRef = useRef<View>(null);
const readActionSkipped = useRef(false);
const listRef = useRef<FlatList<OnyxTypes.ReportAction> | null>(null);
useEffect(() => {
registerListRef(listRef);
return () => registerListRef(null);
}, [registerListRef]);
const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport);
const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated;
const userActiveSince = useRef<string>(DateUtils.getDBTime());
Expand All @@ -222,7 +227,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
reportID,
reportActions,
allReportActionIDs: reportActionIDs,
transactionThreadReport,
transactionThreadReportID: transactionThreadReport?.reportID,
hasOlderActions,
hasNewerActions,
newestFetchedReportActionID: reportMetadata?.newestFetchedReportActionID,
Expand Down Expand Up @@ -309,8 +314,6 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
loadNewerChats(false);
}, [loadNewerChats]);

const prevUnreadMarkerReportActionID = useRef<string | null>(null);

const visibleActionsMap = useMemo(() => {
return visibleReportActions.reduce((actionsMap, reportAction) => {
Object.assign(actionsMap, {
Expand Down Expand Up @@ -422,11 +425,9 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
prevSortedVisibleReportActionsObjects: prevVisibleActionsMap,
unreadMarkerTime,
scrollingVerticalOffset: scrollingVerticalBottomOffset.current,
prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current,
isOffline,
isReversed: true,
});
prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID;

const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({
reportID: report.reportID,
Expand Down Expand Up @@ -730,7 +731,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
keyboardShouldPersistTaps="handled"
onScroll={trackVerticalScrolling}
contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]}
ref={reportScrollManager.ref}
ref={listRef}
ListEmptyComponent={!isOffline && showReportActionsLoadingState ? <ReportActionsListLoadingSkeleton reasonAttributes={skeletonReasonAttributes} /> : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState
removeClippedSubviews={false}
initialScrollKey={linkedReportActionID}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {cancelSpan} from '@libs/telemetry/activeSpans';
import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd';
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
import Navigation from '@navigation/Navigation';
import ReportActionsView from '@pages/inbox/report/ReportActionsView';
import ReportActionsList from '@pages/inbox/report/ReportActionsList';
import ReportFooter from '@pages/inbox/report/ReportFooter';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
Expand Down Expand Up @@ -149,7 +149,7 @@ function MoneyRequestReportView({report, reportMetadata, shouldDisplayReportFoot
}, [reportID]);

// Special case handling a report that is a transaction thread
// If true we will use standard `ReportActionsView` to display report data and a special header, anything else is handled via `MoneyRequestReportActionsList`
// If true we will use standard `ReportActionsList` to display report data and a special header, anything else is handled via `MoneyRequestReportActionsList`
const isTransactionThreadView = isReportTransactionThread(report);

// Prevent the empty state flash by ensuring transaction data is fully loaded before deciding which view to render
Expand Down Expand Up @@ -275,8 +275,8 @@ function MoneyRequestReportView({report, reportMetadata, shouldDisplayReportFoot
{shouldDisplayMoneyRequestActionsList ? (
<MoneyRequestReportActionsList onLayout={onLayout} />
) : (
<ReportActionsView
reportID={reportID}
<ReportActionsList
reportID={report.reportID}
onLayout={onLayout}
/>
)}
Expand Down
16 changes: 13 additions & 3 deletions src/hooks/useActionListContextValue.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import {useRef} from 'react';
import type {FlatList} from 'react-native';
import type {RefObject} from 'react';
import type {ActionListContextType, ScrollPosition} from '@pages/inbox/ReportScreenContext';

function useActionListContextValue(): ActionListContextType {
const flatListRef = useRef<FlatList>(null);
// Private holder — the registered list ref from whichever child list is currently mounted.
// Owning the ref here (instead of exposing a RefObject through context) lets React Compiler
// optimize descendants that previously had to pass a context-owned ref to JSX `ref={}`.
const listRefHolder = useRef<RefObject<unknown> | null>(null);
const scrollPositionRef = useRef<ScrollPosition>({});
const scrollOffsetRef = useRef(0);

return {flatListRef, scrollPositionRef, scrollOffsetRef};
const registerListRef = (ref: RefObject<unknown> | null) => {
listRefHolder.current = ref;
};
// Caller-side types vary (FlashListRef | FlatList). We expose the duck-typed imperative
// interface here so scroll handlers can invoke scrollTo* without per-caller casts.
const getListRef: ActionListContextType['getListRef'] = () => listRefHolder.current as ReturnType<ActionListContextType['getListRef']>;

return {registerListRef, getListRef, scrollPositionRef, scrollOffsetRef};
}

export default useActionListContextValue;
20 changes: 9 additions & 11 deletions src/hooks/useLoadReportActions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import {useIsFocused} from '@react-navigation/native';
import type {OnyxEntry} from 'react-native-onyx';
import {getNewerActions, getOlderActions} from '@userActions/Report';
import CONST from '@src/CONST';
import type {Report, ReportAction} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {ReportAction} from '@src/types/onyx';
import useNetwork from './useNetwork';

type UseLoadReportActionsArguments = {
Expand All @@ -16,8 +14,8 @@ type UseLoadReportActionsArguments = {
/** The IDs of all reportActions linked to the current report (may contain some extra actions) */
allReportActionIDs: string[];

/** The transaction thread report associated with the current transaction, if any */
transactionThreadReport: OnyxEntry<Report>;
/** The transaction thread report ID associated with the current transaction, if any */
transactionThreadReportID: string | undefined;

/** If the report has newer actions to load */
hasNewerActions: boolean;
Expand All @@ -37,7 +35,7 @@ function useLoadReportActions({
reportID,
reportActions,
allReportActionIDs,
transactionThreadReport,
transactionThreadReportID,
hasOlderActions,
hasNewerActions,
newestFetchedReportActionID,
Expand All @@ -47,7 +45,7 @@ function useLoadReportActions({
const newestReportAction = reportActions?.at(0);
const oldestReportAction = reportActions?.at(-1);

const isTransactionThreadReport = !isEmptyObject(transactionThreadReport);
const isTransactionThreadReport = !!transactionThreadReportID;

let currentReportNewestAction = null;
let currentReportOldestAction = null;
Expand All @@ -59,7 +57,7 @@ function useLoadReportActions({
for (const action of reportActions) {
// Determine which report this action belongs to
const isCurrentReport = allReportActionIDsSet.has(action.reportActionID);
const targetReportID = isCurrentReport ? reportID : transactionThreadReport?.reportID;
const targetReportID = isCurrentReport ? reportID : transactionThreadReportID;

// Track newest/oldest per report
if (targetReportID === reportID) {
Expand All @@ -69,7 +67,7 @@ function useLoadReportActions({
}
// Oldest = last matching action we encounter
currentReportOldestAction = action;
} else if (isTransactionThreadReport && transactionThreadReport?.reportID === targetReportID) {
} else if (isTransactionThreadReport && transactionThreadReportID === targetReportID) {
// Same logic for transaction thread
if (!transactionThreadNewestAction) {
transactionThreadNewestAction = action;
Expand All @@ -95,7 +93,7 @@ function useLoadReportActions({

if (isTransactionThreadReport) {
getOlderActions(reportID, currentReportOldestAction?.reportActionID);
getOlderActions(transactionThreadReport?.reportID, transactionThreadOldestAction?.reportActionID);
getOlderActions(transactionThreadReportID, transactionThreadOldestAction?.reportActionID);
} else {
getOlderActions(reportID, currentReportOldestAction?.reportActionID);
}
Expand Down Expand Up @@ -124,7 +122,7 @@ function useLoadReportActions({

if (isTransactionThreadReport) {
getNewerActions(reportID, currentReportNewestAction?.reportActionID);
getNewerActions(transactionThreadReport.reportID, transactionThreadNewestAction?.reportActionID);
getNewerActions(transactionThreadReportID, transactionThreadNewestAction?.reportActionID);
} else if (newestReportAction) {
getNewerActions(reportID, newestReportAction.reportActionID);
}
Expand Down
Loading
Loading