-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Open linked message at top #93403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
JS00001
merged 26 commits into
Expensify:main
from
callstack-internal:VickyStash/feature/open-linked-message-centered
Jun 23, 2026
Merged
Open linked message at top #93403
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
2fdcd6f
Center linked message position
VickyStash 937919c
Improve centering
VickyStash 321246d
Update patch, add details
VickyStash 3f20d6c
Fix patch
VickyStash 4709b60
Fix pagination jest test
VickyStash 7d6789c
Try removing extra correction to fix tests
VickyStash 6b9c8d8
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash 48b5c9e
Remove useFlashListScrollKey at all
VickyStash 0d41904
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash ffec630
Improve patch
VickyStash 46971d2
Simplify code
VickyStash 7a49d3b
Merge remote-tracking branch 'origin/main' into VickyStash/feature/op…
VickyStash 3a7b90a
Remove patch 008 updates
VickyStash cc8aae3
Fix patch
VickyStash ecd19c2
Peek next message at bottom edge when linked message is centered
VickyStash 8ac0cf6
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash 57cf75e
Change position to top with 40px offset. Adjust correction.
VickyStash dff285e
Show loading while fetching the report to keep linked message anchor …
VickyStash e484da8
Improve details.md
VickyStash 40a2da8
Re-run checks
VickyStash e29132c
Fix test
VickyStash 36f6a04
Gate the linked-message skeleton on isLoadingInitialReportActions
VickyStash e71858b
Remove not necessary anymore change
VickyStash fd24728
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash 1e16a97
Merge branch 'main' into VickyStash/feature/open-linked-message-centered
VickyStash 9e36d12
Put offset into CONST
VickyStash File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
104 changes: 104 additions & 0 deletions
104
patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts | ||
| index fa786bf..586014c 100644 | ||
| --- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts | ||
| +++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts | ||
| @@ -127,10 +127,12 @@ export interface FlashListProps<TItem> extends Omit<ScrollViewProps, "maintainVi | ||
| /** | ||
| * Additional configuration for initialScrollIndex. | ||
| * Use viewOffset to apply an offset to the initial scroll position as defined by initialScrollIndex. | ||
| + * Use viewPosition to position the item within the viewport (0 = start, 0.5 = center, 1 = end), mirroring scrollToIndex. | ||
| * Ignored if initialScrollIndex is not set. | ||
| */ | ||
| initialScrollIndexParams?: { | ||
| viewOffset?: number; | ||
| + viewPosition?: number; | ||
| } | null | undefined; | ||
| /** | ||
| * Used to extract a unique key for a given item at the specified index. | ||
| diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js | ||
| index 3b69234..41bfb84 100644 | ||
| --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js | ||
| +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js | ||
| @@ -294,11 +294,26 @@ export class RecyclerViewManager { | ||
| // re-estimate unmeasured items with an updated average height, changing | ||
| // the target item's position. Reading before recompute would capture a | ||
| // stale offset, causing the wrong items to be rendered. | ||
| - this.layoutManager.recomputeLayouts(0, initialScrollIndex); | ||
| + this.layoutManager.recomputeLayouts(0, this.getDataLength() - 1); | ||
| const initialItemLayout = this.layoutManager.getLayout(initialScrollIndex); | ||
| - const initialItemOffset = this.propsRef.horizontal | ||
| + let initialItemOffset = this.propsRef.horizontal | ||
| ? initialItemLayout.x | ||
| : initialItemLayout.y; | ||
| + // Anchor the initial render window according to initialScrollIndexParams.viewPosition so the | ||
| + // first painted frame already shows the target item at the requested position in the viewport. | ||
| + const initialScrollIndexParams = this.propsRef.initialScrollIndexParams; | ||
| + const viewPosition = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewPosition; | ||
| + if (viewPosition !== undefined) { | ||
| + const windowSize = this.propsRef.horizontal | ||
| + ? this.getWindowSize().width | ||
| + : this.getWindowSize().height; | ||
| + const itemSize = this.propsRef.horizontal | ||
| + ? initialItemLayout.width | ||
| + : initialItemLayout.height; | ||
| + if (windowSize > 0) { | ||
| + initialItemOffset = Math.max(0, initialItemOffset - (windowSize - itemSize) * viewPosition); | ||
| + } | ||
| + } | ||
| this.engagedIndicesTracker.scrollOffset = initialItemOffset; | ||
| } | ||
| else { | ||
| diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js | ||
| index 18e59ce..17c8063 100644 | ||
| --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js | ||
| +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js | ||
| @@ -566,10 +566,45 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe | ||
| }, 500); | ||
| pauseOffsetCorrection.current = true; | ||
| const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; | ||
| - const offset = horizontal | ||
| - ? recyclerViewManager.getLayout(initialScrollIndex).x + additionalOffset | ||
| - : recyclerViewManager.getLayout(initialScrollIndex).y + | ||
| - additionalOffset; | ||
| + const initialItemLayout = recyclerViewManager.getLayout(initialScrollIndex); | ||
| + let offset = (horizontal ? initialItemLayout.x : initialItemLayout.y) + | ||
| + additionalOffset; | ||
| + // Position the target item within the viewport (0 = start, 0.5 = center, 1 = end), mirroring scrollToIndex. | ||
| + const viewPosition = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewPosition; | ||
| + if (viewPosition !== undefined) { | ||
| + const containerSize = horizontal | ||
| + ? recyclerViewManager.getWindowSize().width | ||
| + : recyclerViewManager.getWindowSize().height; | ||
| + const itemSize = horizontal | ||
| + ? initialItemLayout.width | ||
| + : initialItemLayout.height; | ||
| + if (containerSize > 0) { | ||
| + offset = Math.max(0, offset - (containerSize - itemSize) * viewPosition); | ||
| + } | ||
| + } | ||
| + // Make it clear there are more items to scroll to underneath the bottom edge. | ||
| + // If the bottom item is (essentially) fully visible against the bottom edge AND there | ||
| + // is an item underneath it, nudge the bottom edge up so CROP_OFFSET px of the current | ||
| + // bottom item gets cropped, signalling that more content can be scrolled into view. | ||
| + if (viewPosition !== undefined && !horizontal && recyclerViewManager.props.inverted && offset > 0) { | ||
| + const CROP_OFFSET = 10; | ||
| + let bottomIndex = -1; | ||
| + for (let i = initialScrollIndex; i >= 0; i--) { | ||
| + if (recyclerViewManager.getLayout(i).y <= offset) { | ||
| + bottomIndex = i; | ||
| + break; | ||
| + } | ||
| + } | ||
| + if (bottomIndex > 0) { | ||
| + const bottomItemLayout = recyclerViewManager.getLayout(bottomIndex); | ||
| + const hiddenPortion = offset - bottomItemLayout.y; | ||
| + // 8px is bottom padding of every item | ||
| + if (hiddenPortion <= 8) { | ||
| + // Crop the current bottom item rather than letting it sit flush against the edge. | ||
| + offset = bottomItemLayout.y + CROP_OFFSET; | ||
| + } | ||
| + } | ||
| + } | ||
| handlerMethods.scrollToOffset({ | ||
| offset, | ||
| animated: false, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -359,22 +359,8 @@ function ReportActionsList({ | |
| return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); | ||
| }, [parentReportAction, renderedVisibleReportActions, report]); | ||
|
|
||
| // Precompute a reportActionID -> index map so renderItem can resolve the real index in O(1) | ||
| // instead of scanning renderedVisibleReportActions with indexOf on every render. | ||
| const actionIndexMap = useMemo(() => { | ||
| const map = new Map<string, number>(); | ||
| for (const [i, action] of renderedVisibleReportActions.entries()) { | ||
| map.set(action.reportActionID, i); | ||
| } | ||
| return map; | ||
| }, [renderedVisibleReportActions]); | ||
|
|
||
| const renderItem = useCallback( | ||
| ({item: reportAction, index}: ListRenderItemInfo<OnyxTypes.ReportAction>) => { | ||
| // Use the action's actual index in sortedVisibleReportActions rather than the FlashList-provided index, | ||
| // because useFlashListScrollKey may slice the data for deep-link scroll positioning, making the | ||
| // FlashList index offset from the full array and causing wrong displayAsGroup computation. | ||
| const safeIndex = actionIndexMap.get(reportAction.reportActionID) ?? index; | ||
| const shouldDisableContextMenuForConciergeDraft = draftReportActionID === reportAction.reportActionID; | ||
|
|
||
| return ( | ||
|
|
@@ -388,8 +374,8 @@ function ReportActionsList({ | |
| chatReport={chatReportStable} | ||
| linkedReportActionID={linkedReportActionID} | ||
| displayAsGroup={ | ||
| !isConsecutiveChronosAutomaticTimerAction(renderedVisibleReportActions, safeIndex, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && | ||
| isConsecutiveActionMadeByPreviousActor(renderedVisibleReportActions, safeIndex, isOffline) | ||
| !isConsecutiveChronosAutomaticTimerAction(renderedVisibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && | ||
| isConsecutiveActionMadeByPreviousActor(renderedVisibleReportActions, index, isOffline) | ||
| } | ||
| shouldHideThreadDividerLine={shouldHideThreadDividerLine} | ||
| shouldDisplayNewMarker={reportAction.reportActionID === unreadMarkerReportActionID} | ||
|
|
@@ -412,7 +398,6 @@ function ReportActionsList({ | |
| ); | ||
| }, | ||
| [ | ||
| actionIndexMap, | ||
| draftReportActionID, | ||
| firstVisibleReportActionID, | ||
| hasPreviousMessages, | ||
|
|
@@ -476,6 +461,28 @@ function ReportActionsList({ | |
| isTrackIntentUser, | ||
| }); | ||
|
|
||
| const targetIndex = initialScrollKey ? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey) : -1; | ||
|
JS00001 marked this conversation as resolved.
|
||
| let initialScrollIndex: number | undefined; | ||
| let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; | ||
| if (targetIndex > 0) { | ||
| initialScrollIndex = targetIndex; | ||
| initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; | ||
| } else if (shouldFocusToTopOnMount) { | ||
| initialScrollIndex = renderedVisibleReportActions.length - 1; | ||
| initialScrollIndexParams = {viewOffset: windowHeight}; | ||
| } | ||
|
Comment on lines
+464
to
+473
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could you add plain english explanation of why we doing this so its easier to follow for other devs? |
||
|
|
||
| const maintainVisibleContentPosition = { | ||
| disabled: !shouldMaintainVisibleContentPosition, | ||
| ...(shouldAutoscrollToBottom ? {autoscrollToBottomThreshold: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD, animateAutoScrollToBottom: false} : {}), | ||
| }; | ||
|
|
||
| // When opening a linked message, wait for the first load before rendering the list: the batch of actions that | ||
| // arrives right after the initial load shifts the list and breaks the anchor to the linked action. | ||
| if (initialScrollKey && !isOffline && !reportLoadingState?.hasOnceLoadedReportActions && reportLoadingState?.isLoadingInitialReportActions) { | ||
| return <ReportActionsSkeletonView />; | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| <FloatingMessageCounter | ||
|
|
@@ -523,14 +530,10 @@ function ReportActionsList({ | |
| contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, | ||
| }} | ||
| getItemType={(item) => item.actionName} | ||
| shouldMaintainVisibleContentPosition={shouldMaintainVisibleContentPosition} | ||
| initialScrollIndex={shouldFocusToTopOnMount ? renderedVisibleReportActions.length - 1 : undefined} | ||
| initialScrollIndexParams={shouldFocusToTopOnMount ? {viewOffset: windowHeight} : undefined} | ||
| maintainVisibleContentPosition={ | ||
| shouldAutoscrollToBottom ? {autoscrollToBottomThreshold: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD, animateAutoScrollToBottom: false} : undefined | ||
| } | ||
| initialScrollIndex={initialScrollIndex} | ||
| initialScrollIndexParams={initialScrollIndexParams} | ||
| maintainVisibleContentPosition={maintainVisibleContentPosition} | ||
| onLoad={onLoad} | ||
| initialScrollKey={initialScrollKey} | ||
| onContentSizeChange={() => { | ||
| trackVerticalScrolling(undefined); | ||
| }} | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a linked/unread chat opens near the newest edge and
hasNewerActionsis true, this now forwards the realonStartReachedon the very first FlashList mount; the removed scroll-key hook used to replace it with a no-op during the initial positioning handoff. WithonStartReachedThreshold={0.75}, FlashList can immediately callloadNewerChatsAfterTransitions, prepend newer actions, and move the target away from the requested top offset before the initial anchor has settled. Consider deferring/suppressingonStartReacheduntil the initial linked-message positioning has completed.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Previously
onStartReachedwas supressed cause with oldinitialScrollKeylogic, the reportActions array was cut off initially, and user always started at bottom ->onStartReachedwas always triggered by mistake.Since we have changed the approach, I think we don't need this guard anymore.