Skip to content

Commit b9c9d62

Browse files
authored
Merge pull request #94470 from Expensify/revert-93403-VickyStash/feature/open-linked-message-centered
[CP Staging] Revert "Open linked message at top"
2 parents abb2cfc + 92e2cff commit b9c9d62

6 files changed

Lines changed: 128 additions & 146 deletions

File tree

patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch

Lines changed: 0 additions & 104 deletions
This file was deleted.

patches/@shopify/flash-list/details.md

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,3 @@
141141
- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2334
142142
- E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263
143143
- PR introducing patch: https://github.com/Expensify/App/pull/92520
144-
145-
### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch)
146-
147-
- Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`). Four changes:
148-
1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math.
149-
2. **`applyInitialScrollAdjustment`** in `RecyclerViewManager.js`: the initial render window is anchored with the same `viewPosition` adjustment, so the very first painted frame already renders the items around the centered position — without this, the first frame renders items from the target's raw offset (target at the viewport edge) and visibly jumps once the first corrective scroll lands.
150-
3. **Bottom crop** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): for inverted vertical lists positioned via `viewPosition`, when the bottom-most visible item is flush against the bottom edge and another item exists underneath it, the offset is nudged up so the current bottom item is cropped by a few pixels — signaling there is more content below.
151-
4. **`recomputeLayouts` range** in `applyInitialScrollAdjustment` (`RecyclerViewManager.js`): the recompute that precedes reading the target offset is widened from `recomputeLayouts(0, initialScrollIndex)` to `recomputeLayouts(0, this.getDataLength() - 1)`, so every item gets a measured/re-estimated layout before the positioning.
152-
- Files changed: `dist/FlashListProps.d.ts`, `dist/recyclerview/hooks/useRecyclerViewController.js`, `dist/recyclerview/RecyclerViewManager.js`.
153-
- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2318 (for point 4)
154-
- E/App issue: https://github.com/Expensify/App/issues/92152
155-
- PR introducing patch: https://github.com/Expensify/App/pull/93403

src/CONST/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1839,7 +1839,6 @@ const CONST = {
18391839
THREAD_DISABLED: ['CREATED'],
18401840
LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD: 2000,
18411841
ACTION_VISIBLE_THRESHOLD: 250,
1842-
LINKED_MESSAGE_OFFSET: 40,
18431842
MAX_GROUPING_TIME: 300000,
18441843
},
18451844
CANCEL_PAYMENT_REASONS: {

src/components/FlashList/InvertedFlashList/index.tsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type {FlashListProps} from '@shopify/flash-list';
22
import React from 'react';
3+
import useFlashListScrollKey from '@components/FlashList/useFlashListScrollKey';
34
import type {FlatListRefType} from '@pages/inbox/ReportScreenContext';
45
import FlashList from '..';
56
import CellRendererComponent from './CellRendererComponent';
67

78
type InvertedFlashListProps<T> = FlashListProps<T> & {
9+
/** Key of the item to initially scroll to when the list first renders. */
10+
initialScrollKey?: string | null;
11+
812
/** The array of items to render in the list. */
913
data: T[];
1014

@@ -13,14 +17,48 @@ type InvertedFlashListProps<T> = FlashListProps<T> & {
1317

1418
/** Ref to the underlying list instance. */
1519
ref: FlatListRefType;
20+
21+
/** Whether the list should handle `maintainVisibleContentPosition` */
22+
shouldMaintainVisibleContentPosition?: boolean;
1623
};
1724

18-
function InvertedFlashList<T>(props: InvertedFlashListProps<T>) {
25+
function InvertedFlashList<T>({
26+
data,
27+
keyExtractor,
28+
initialScrollKey,
29+
onStartReached: onStartReachedProp,
30+
maintainVisibleContentPosition: maintainVisibleContentPositionProp,
31+
shouldMaintainVisibleContentPosition,
32+
...restProps
33+
}: InvertedFlashListProps<T>) {
34+
const {
35+
displayedData,
36+
onStartReached,
37+
maintainVisibleContentPosition: maintainVisibleContentPositionForScrollKey,
38+
} = useFlashListScrollKey<T>({
39+
data,
40+
keyExtractor,
41+
initialScrollKey,
42+
onStartReached: onStartReachedProp,
43+
shouldMaintainVisibleContentPosition,
44+
});
45+
46+
const maintainVisibleContentPosition = maintainVisibleContentPositionProp
47+
? {
48+
...maintainVisibleContentPositionForScrollKey,
49+
...maintainVisibleContentPositionProp,
50+
}
51+
: maintainVisibleContentPositionForScrollKey;
52+
1953
return (
2054
<FlashList<T>
21-
{...props}
55+
{...restProps}
2256
inverted
57+
onStartReached={onStartReached}
58+
data={displayedData}
59+
keyExtractor={keyExtractor}
2360
CellRendererComponent={CellRendererComponent}
61+
maintainVisibleContentPosition={maintainVisibleContentPosition}
2462
/>
2563
);
2664
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type {FlashListProps} from '@shopify/flash-list';
2+
import {useEffect, useState} from 'react';
3+
4+
type FlashListScrollKeyProps<T> = {
5+
/** The array of items to render in the list. */
6+
data: T[];
7+
8+
/** Function that extracts a unique key for each item in the list. */
9+
keyExtractor: (item: T, index: number) => string;
10+
11+
/** Key of the item to initially scroll to when the list first renders. */
12+
initialScrollKey: string | null | undefined;
13+
14+
/** Callback invoked when the user scrolls close to the start of the list. */
15+
onStartReached: FlashListProps<T>['onStartReached'];
16+
17+
/** Whether the list should handle `maintainVisibleContentPosition` */
18+
shouldMaintainVisibleContentPosition?: boolean;
19+
};
20+
21+
export default function useFlashListScrollKey<T>({data, keyExtractor, initialScrollKey, onStartReached, shouldMaintainVisibleContentPosition}: FlashListScrollKeyProps<T>) {
22+
const [isInitialRender, setIsInitialRender] = useState(!!initialScrollKey);
23+
const [hasLinkingSettled, setHasLinkingSettled] = useState(!initialScrollKey);
24+
25+
// Two-frame handoff for deep-link:
26+
// RAF 1: switch from sliced data to the full array — FlashList's default MVCP pins the
27+
// linked item through the data swap.
28+
// RAF 2: pinning has happened, disable MVCP so it doesn't cause later jumps.
29+
useEffect(() => {
30+
if (!isInitialRender) {
31+
return;
32+
}
33+
34+
// Without an anchor on this frame, we are not doing the deep-link slice handoff; clear the flag so a key that
35+
// appears later (e.g. marking a message unread) cannot reuse the "first paint" slice path.
36+
if (!initialScrollKey) {
37+
// If the initial scroll key gets unset, we need to disable the initial render flag,
38+
// otherwise the list will not render..
39+
// eslint-disable-next-line react-hooks/set-state-in-effect
40+
setIsInitialRender(false);
41+
return;
42+
}
43+
44+
requestAnimationFrame(() => {
45+
setIsInitialRender(false);
46+
requestAnimationFrame(() => setHasLinkingSettled(true));
47+
});
48+
}, [isInitialRender, initialScrollKey]);
49+
50+
const maintainVisibleContentPosition: FlashListProps<T>['maintainVisibleContentPosition'] = {disabled: !shouldMaintainVisibleContentPosition && hasLinkingSettled};
51+
52+
if (!isInitialRender || !initialScrollKey) {
53+
return {displayedData: data, onStartReached, maintainVisibleContentPosition};
54+
}
55+
56+
const targetIndex = data.findIndex((item, index) => keyExtractor(item, index) === initialScrollKey);
57+
if (targetIndex <= 0) {
58+
return {displayedData: data, onStartReached, maintainVisibleContentPosition};
59+
}
60+
61+
// On the first render, slice from the target onward so the target item
62+
// appears at the visual bottom of the inverted list — no scrolling needed.
63+
return {displayedData: data.slice(targetIndex), onStartReached: () => {}, maintainVisibleContentPosition};
64+
}

src/pages/inbox/report/ReportActionsList.tsx

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,22 @@ function ReportActionsList({
359359
return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report);
360360
}, [parentReportAction, renderedVisibleReportActions, report]);
361361

362+
// Precompute a reportActionID -> index map so renderItem can resolve the real index in O(1)
363+
// instead of scanning renderedVisibleReportActions with indexOf on every render.
364+
const actionIndexMap = useMemo(() => {
365+
const map = new Map<string, number>();
366+
for (const [i, action] of renderedVisibleReportActions.entries()) {
367+
map.set(action.reportActionID, i);
368+
}
369+
return map;
370+
}, [renderedVisibleReportActions]);
371+
362372
const renderItem = useCallback(
363373
({item: reportAction, index}: ListRenderItemInfo<OnyxTypes.ReportAction>) => {
374+
// Use the action's actual index in sortedVisibleReportActions rather than the FlashList-provided index,
375+
// because useFlashListScrollKey may slice the data for deep-link scroll positioning, making the
376+
// FlashList index offset from the full array and causing wrong displayAsGroup computation.
377+
const safeIndex = actionIndexMap.get(reportAction.reportActionID) ?? index;
364378
const shouldDisableContextMenuForConciergeDraft = draftReportActionID === reportAction.reportActionID;
365379

366380
return (
@@ -374,8 +388,8 @@ function ReportActionsList({
374388
chatReport={chatReportStable}
375389
linkedReportActionID={linkedReportActionID}
376390
displayAsGroup={
377-
!isConsecutiveChronosAutomaticTimerAction(renderedVisibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) &&
378-
isConsecutiveActionMadeByPreviousActor(renderedVisibleReportActions, index, isOffline)
391+
!isConsecutiveChronosAutomaticTimerAction(renderedVisibleReportActions, safeIndex, chatIncludesChronosWithID(reportAction?.reportID), isOffline) &&
392+
isConsecutiveActionMadeByPreviousActor(renderedVisibleReportActions, safeIndex, isOffline)
379393
}
380394
shouldHideThreadDividerLine={shouldHideThreadDividerLine}
381395
shouldDisplayNewMarker={reportAction.reportActionID === unreadMarkerReportActionID}
@@ -398,6 +412,7 @@ function ReportActionsList({
398412
);
399413
},
400414
[
415+
actionIndexMap,
401416
draftReportActionID,
402417
firstVisibleReportActionID,
403418
hasPreviousMessages,
@@ -461,28 +476,6 @@ function ReportActionsList({
461476
isTrackIntentUser,
462477
});
463478

464-
const targetIndex = initialScrollKey ? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey) : -1;
465-
let initialScrollIndex: number | undefined;
466-
let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined;
467-
if (targetIndex > 0) {
468-
initialScrollIndex = targetIndex;
469-
initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET};
470-
} else if (shouldFocusToTopOnMount) {
471-
initialScrollIndex = renderedVisibleReportActions.length - 1;
472-
initialScrollIndexParams = {viewOffset: windowHeight};
473-
}
474-
475-
const maintainVisibleContentPosition = {
476-
disabled: !shouldMaintainVisibleContentPosition,
477-
...(shouldAutoscrollToBottom ? {autoscrollToBottomThreshold: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD, animateAutoScrollToBottom: false} : {}),
478-
};
479-
480-
// When opening a linked message, wait for the first load before rendering the list: the batch of actions that
481-
// arrives right after the initial load shifts the list and breaks the anchor to the linked action.
482-
if (initialScrollKey && !isOffline && !reportLoadingState?.hasOnceLoadedReportActions && reportLoadingState?.isLoadingInitialReportActions) {
483-
return <ReportActionsSkeletonView />;
484-
}
485-
486479
return (
487480
<>
488481
<FloatingMessageCounter
@@ -530,10 +523,14 @@ function ReportActionsList({
530523
contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined,
531524
}}
532525
getItemType={(item) => item.actionName}
533-
initialScrollIndex={initialScrollIndex}
534-
initialScrollIndexParams={initialScrollIndexParams}
535-
maintainVisibleContentPosition={maintainVisibleContentPosition}
526+
shouldMaintainVisibleContentPosition={shouldMaintainVisibleContentPosition}
527+
initialScrollIndex={shouldFocusToTopOnMount ? renderedVisibleReportActions.length - 1 : undefined}
528+
initialScrollIndexParams={shouldFocusToTopOnMount ? {viewOffset: windowHeight} : undefined}
529+
maintainVisibleContentPosition={
530+
shouldAutoscrollToBottom ? {autoscrollToBottomThreshold: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD, animateAutoScrollToBottom: false} : undefined
531+
}
536532
onLoad={onLoad}
533+
initialScrollKey={initialScrollKey}
537534
onContentSizeChange={() => {
538535
trackVerticalScrolling(undefined);
539536
}}

0 commit comments

Comments
 (0)