Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions src/components/FlashList/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import type {FlashListRef} from '@shopify/flash-list';
import type {RefObject} from 'react';
import type ActionListRefType from '@pages/inbox/ActionListTypes';

/** Ref to the underlying list instance attached via `ref={}`. */
type FlatListRefType = RefObject<FlashListRef<unknown> | null> | null;

export default FlatListRefType;
export default ActionListRefType;
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,20 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon
fullPolicySnapshot: report?.policyID && fullPolicySnapshotLastModified ? {policyID: report.policyID, lastModified: fullPolicySnapshotLastModified} : undefined,
});

const [editScrollRequestID, setEditScrollRequestID] = useState(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty confused by this variable name. What's an "edit scroll request"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming this in #100733

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chris's AI teammate here. 🤖

Renamed it to scrollToNewestRequestID in #100733, with lastHandledScrollToNewestRequestIDRef tracking which request was handled. It requests a scroll to the newest action after closing its editor. I also added a comment explaining why FlashList must measure the updated rows before scrolling.

const completedEditScrollRequestIDRef = useRef(0);
const requestScrollToNewestAfterEdit = () => {
// Force a layout commit even when closing the editor leaves the row's height unchanged.
setEditScrollRequestID((requestID) => requestID + 1);
};
const onCommitLayoutEffect = () => {
if (editScrollRequestID <= completedEditScrollRequestIDRef.current) {
return;
}
completedEditScrollRequestIDRef.current = editScrollRequestID;
scrollToBottom();
};

// Wait for the actions to arrive before jumping, and clear the route param afterwards so a later remount
// doesn't yank the user back down.
useEffect(() => {
Expand All @@ -275,7 +289,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon
const isNewestReportAction = indexWithinReportActions === displayReportActions.length - 1;

return (
<ReportActionScrollToNewestContext.Provider value={scrollToBottom}>
<ReportActionScrollToNewestContext.Provider value={requestScrollToNewestAfterEdit}>
<ReportActionPositionContextProvider
index={indexWithinReportActions}
isNewest={isNewestReportAction}
Expand Down Expand Up @@ -411,6 +425,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon
onLastItemIndexChange={updateLastItemIndex}
accessibilityLabel={translate('sidebarScreen.listOfChatMessages')}
onListLayout={onListLayout}
onCommitLayoutEffect={onCommitLayoutEffect}
onScroll={trackVerticalScrolling}
onScrollBeginDrag={onListScrollBeginDrag}
onContentSizeChange={onListContentSizeChange}
Expand All @@ -421,7 +436,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon
contentContainerStyle={[styles.pt5, shouldReserveBulkActionBarSpace && styles.bulkActionBarListSpacing]}
isLoadingInitialActions={isInitialReportLoadPending}
/* This list is not inverted, so the footer is the bottom of the message feed —
the same position the indicator occupies in the inverted ReportActionsList. */
the same position the indicator occupies in ReportActionsList. */
listFooterComponent={<ConciergeThinkingMessage reportID={report.reportID} />}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import LinkButton from '@components/Button/composed/LinkButton';
import type FlatListRefType from '@components/FlashList/types';

import useCopySelectionEvent from '@hooks/useCopySelectionEvent';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
Expand All @@ -18,6 +17,8 @@ import {getTransactionPendingAction} from '@libs/TransactionUtils';

import Navigation from '@navigation/Navigation';

import type ActionListRefType from '@pages/inbox/ActionListTypes';

import {resolveLayoutSpacing} from '@styles/layoutSpacing';

import CONST from '@src/CONST';
Expand Down Expand Up @@ -131,7 +132,7 @@ type MoneyRequestReportTransactionListProps = {
focusRequestedReportActionID?: string;

/** Ref forwarded to the underlying FlashList. */
listRef: FlatListRefType;
listRef: ActionListRefType;

/** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */
onLastItemIndexChange?: (index: number) => void;
Expand All @@ -142,6 +143,9 @@ type MoneyRequestReportTransactionListProps = {
/** FlashList onLayout callback (distinct from the empty-state `onLayout` above). */
onListLayout: (event: LayoutChangeEvent) => void;

/** Called after FlashList finishes measuring and committing its rows. */
onCommitLayoutEffect?: () => void;

/** FlashList onScroll callback. */
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;

Expand Down Expand Up @@ -193,6 +197,7 @@ function MoneyRequestReportTransactionList({
onLastItemIndexChange,
accessibilityLabel,
onListLayout,
onCommitLayoutEffect,
onScroll,
onScrollBeginDrag,
onContentSizeChange,
Expand Down Expand Up @@ -490,6 +495,7 @@ function MoneyRequestReportTransactionList({
onLastItemIndexChange={onLastItemIndexChange}
accessibilityLabel={accessibilityLabel}
onLayout={onListLayout}
onCommitLayoutEffect={onCommitLayoutEffect}
onScroll={onScroll}
onScrollBeginDrag={onScrollBeginDrag}
onContentSizeChange={onContentSizeChange}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import FlashList from '@components/FlashList';
import type FlatListRefType from '@components/FlashList/types';

import useWindowDimensions from '@hooks/useWindowDimensions';

import type ActionListRefType from '@pages/inbox/ActionListTypes';
import useScrollToEditingReportAction from '@pages/inbox/report/useScrollToEditingReportAction';

import variables from '@styles/variables';
Expand Down Expand Up @@ -48,14 +48,13 @@ function unifiedListItemType(item: UnifiedListItem) {
}

type MoneyRequestReportFlashListProps = FlashListProps<UnifiedListItem> & {
/** Ref to the underlying list, shared via the ActionList context. */
ref: FlatListRefType;
/** Ref to the underlying list, shared via the common ActionList imperative API. */
ref: ActionListRefType;
};

/**
* Forwards the shared ActionList context ref to the underlying FlashList. Mirroring InvertedFlashList, the
* ref is forwarded through @components/FlashList — which receives it as an untyped runtime prop — so no type assertion is
* needed. The scroll manager relies on the FlashList registering into this slot.
* Forwards the shared ActionList ref through @components/FlashList to the underlying list.
* FlashList implements the common imperative API consumed by the report scroll manager.
*/
function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) {
return (
Expand Down Expand Up @@ -95,13 +94,16 @@ type MoneyRequestReportUnifiedListProps = {
newTransactionID?: string;

/** Ref to the underlying list, shared via the ActionList context. */
listRef: FlatListRefType;
listRef: ActionListRefType;

accessibilityLabel: string;

/** Called when the list lays out. */
onLayout: (event: LayoutChangeEvent) => void;

/** Called after FlashList finishes measuring and committing its rows. */
onCommitLayoutEffect?: () => void;

onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;

/** Called when the user starts dragging the list. */
Expand Down Expand Up @@ -145,6 +147,7 @@ function MoneyRequestReportUnifiedList({
listRef,
accessibilityLabel,
onLayout,
onCommitLayoutEffect,
onScroll,
onScrollBeginDrag,
onContentSizeChange,
Expand Down Expand Up @@ -380,6 +383,7 @@ function MoneyRequestReportUnifiedList({
maintainVisibleContentPosition={{autoscrollToBottomThreshold: undefined}}
onViewableItemsChanged={onViewableItemsChangedAdjusted}
onLayout={handleLayout}
onCommitLayoutEffect={onCommitLayoutEffect}
onEndReached={onEndReached}
onEndReachedThreshold={0.75}
onStartReached={onStartReached}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import PDFThumbnailError from '@components/PDFThumbnail/PDFThumbnailError';

import useThemeStyles from '@hooks/useThemeStyles';

import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext';

import CONST from '@src/CONST';

import React, {useState} from 'react';
import {View} from 'react-native';

import type LocalPDFReceiptPreviewProps from './types';
Expand All @@ -18,9 +19,9 @@ const DOCUMENT_OPTIONS = {cMapUrl: '/cmaps/', cMapPacked: true};

function LocalPDFReceiptPreview({sourceURL, shouldUseFullHeight, onLoadFailure, onLoadSuccess}: LocalPDFReceiptPreviewProps) {
const styles = useThemeStyles();
const [failedToLoad, setFailedToLoad] = useState(false);
const [containerSize, setContainerSize] = useState<{width: number; height: number} | undefined>(undefined);
const [pageAspectRatio, setPageAspectRatio] = useState<number | undefined>(undefined);
const [failedToLoad, setFailedToLoad] = useReportActionItemState(false);
const [containerSize, setContainerSize] = useReportActionItemState<{width: number; height: number} | undefined>(undefined);
const [pageAspectRatio, setPageAspectRatio] = useReportActionItemState<number | undefined>(undefined);

const handleDocumentLoadSuccess = (pdf: PDFDocumentProxy) => {
pdf.getPage(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import useThemeStyles from '@hooks/useThemeStyles';

import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';

import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext';

import variables from '@styles/variables';

import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} from '@userActions/CanvasSize';
Expand Down Expand Up @@ -125,7 +127,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure

// Track which URL failed so hasFailed resets automatically when fileURL changes (e.g. after auth token refresh),
// mirroring the pattern in ThumbnailImage. No useEffect needed — the comparison runs synchronously during render.
const [failedURL, setFailedURL] = useState<string | null>(null);
const [failedURL, setFailedURL] = useReportActionItemState<string | null>(null);
const hasFailed = failedURL !== null && failedURL === fileURL;

// The page last drawn stays on screen while the next one renders, so the thumbnail underneath never flashes
Expand Down
6 changes: 4 additions & 2 deletions src/components/ThumbnailImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ function ThumbnailImage({
const failedToLoad = failedLoadKey !== null && failedLoadKey.url === previewSourceURL && failedLoadKey.isOffline === isOffline;

const cachedDimensions = shouldDynamicallyResize && typeof previewSourceURL === 'string' ? thumbnailDimensionsCache.get(previewSourceURL) : null;
const [imageDimensions, setImageDimensions] = useState({width: cachedDimensions?.width ?? imageWidth, height: cachedDimensions?.height ?? imageHeight});
const [measuredDimensions, setMeasuredDimensions] = useState<{url: string | ImageSourcePropType; width: number; height: number} | null>(null);
const imageDimensions =
measuredDimensions?.url === previewSourceURL ? measuredDimensions : {width: cachedDimensions?.width ?? imageWidth, height: cachedDimensions?.height ?? imageHeight};
const {thumbnailDimensionsStyles} = useThumbnailDimensions(imageDimensions.width, imageDimensions.height);
const StyleUtils = useStyleUtils();

Expand All @@ -118,7 +120,7 @@ function ThumbnailImage({
thumbnailDimensionsCache.set(previewSourceURL, {width, height});
}

setImageDimensions({width, height});
setMeasuredDimensions({url: previewSourceURL, width, height});
};

const sizeStyles = shouldDynamicallyResize ? [thumbnailDimensionsStyles] : [styles.w100, styles.h100];
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/useReportActionsScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,12 @@ function useReportActionsScroll({
getVisibleIndexRange: () => {
const list = getListRef()?.current;
// getLayout is the one accessor that does not throw before FlashList has laid out
if (!list?.getLayout(0)) {
if (!list?.getLayout?.(0)) {
return undefined;
}
const range = list.computeVisibleIndices();
const range = list.computeVisibleIndices?.();
// An empty viewport comes back as {-1, -2}, which would read as "target above", so fall back to the reported items
return range.startIndex < 0 ? undefined : range;
return range && range.startIndex >= 0 ? range : undefined;
},
shouldBeAlignedToTop,
});
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useReportScrollManager/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function useReportScrollManager(): ReportScrollManagerData {
return;
}

const scrollViewRef = listRef.current.getNativeScrollRef();
const scrollViewRef = listRef.current.getNativeScrollRef?.();
// Try to scroll on underlying scrollView if available, fallback to usual listRef
if (scrollViewRef && typeof scrollViewRef.scrollToEnd === 'function') {
scrollViewRef.scrollToEnd({animated: false});
Expand Down
14 changes: 7 additions & 7 deletions src/pages/inbox/ActionListContext.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import type FlatListRefType from '@components/FlashList/types';

import type {FlashListRef} from '@shopify/flash-list';
import type {ReactNode, RefObject} from 'react';

import React, {createContext, useContext, useLayoutEffect, useRef} from 'react';

import type ActionListRefType from './ActionListTypes';
import type {ActionListRef} from './ActionListTypes';

type ActionListContextType = {
scrollOffsetRef: RefObject<number>;

/** Snapshot of the persisted scroll offset. Safe to call during render (e.g. a useState initializer) to restore mount-time scroll state. */
getScrollOffset: () => number;

/** Each list publishes its locally-owned ref on mount; pass `null` to clear on unmount. */
registerListRef: (ref: FlatListRefType) => void;
registerListRef: (ref: ActionListRefType) => void;

/** Reads the currently registered list ref. Call from handlers only, never during render. */
getListRef: () => FlatListRefType;
getListRef: () => ActionListRefType;
};

const ActionListContext = createContext<ActionListContextType>({
Expand All @@ -35,7 +35,7 @@ function useActionListContext() {
*/
function useActionListRef() {
const {registerListRef} = useActionListContext();
const listRef = useRef<FlashListRef<unknown>>(null);
const listRef = useRef<ActionListRef>(null);

useLayoutEffect(() => {
registerListRef(listRef);
Expand All @@ -49,7 +49,7 @@ function useActionListRef() {
function ActionListContextProvider({children}: {children: ReactNode}) {
// Each list owns its own ref locally and publishes it here on mount; only the register/get
// callbacks live in context, so attaching `ref={}` stays local to each list.
const listRefHolder = useRef<FlatListRefType>(null);
const listRefHolder = useRef<ActionListRefType>(null);
const scrollOffsetRef = useRef(0);

const value: ActionListContextType = {
Expand Down
36 changes: 36 additions & 0 deletions src/pages/inbox/ActionListTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type {RefObject} from 'react';

type ScrollToIndexParams = {
animated?: boolean;
index: number;
viewOffset?: number;
viewPosition?: number;
};

type ScrollToOffsetParams = {
animated?: boolean;
offset: number;
};

type ScrollToEndParams = {
animated?: boolean;
};

/** Common imperative API used by the report scroll manager across FlatList, FlashList, and LegendList. */
type ActionListRef = {
scrollToIndex: (params: ScrollToIndexParams) => void;
scrollToOffset: (params: ScrollToOffsetParams) => void;
scrollToEnd: (params?: ScrollToEndParams) => void;
getNativeScrollRef?: () => {scrollToEnd?: (params?: ScrollToEndParams) => void} | null | undefined;
getScrollableNode?: () => unknown;
getLayout?: (index: number) => unknown;
computeVisibleIndices?: () => {startIndex: number; endIndex: number} | undefined;
/** Measured item positions supplied by LegendList. Other list implementations can omit this. */
getState?: () => {positionByKey: (key: string) => number | undefined; scroll?: number; contentLength?: number; scrollLength?: number} | undefined;
};

/** Ref to the underlying list instance attached via `ref={}`. */
type ActionListRefType = RefObject<ActionListRef | null> | null;

export default ActionListRefType;
export type {ActionListRef};
1 change: 1 addition & 0 deletions src/pages/inbox/report/MoneyReportContentCreated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function MoneyReportContentCreated({report, policy, transaction, transactionThre
<ShowContextMenuActionsContext.Provider value={contextMenuActionsValue}>
<View>
<MoneyRequestView
key={action?.reportActionID ?? transactionThreadReport?.reportID}
transactionThreadReport={transactionThreadReport}
parentReportID={transactionThreadReport?.parentReportID}
expensePolicy={policy}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,16 @@ function useEditMessage({

clearAllReportActionDrafts();

// Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report.
if (!shouldScrollToLastMessage) {
return;
}

// Let the list-specific callback handle scrolling after the editor closes.
if (scrollToLastMessage) {
scrollToLastMessage();
return;
}

reportScrollManager.scrollToIndex(0);
reportScrollManager.scrollToBottom();
}

/**
Expand Down
Loading
Loading