Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
399267c
feat: use `initialScrollIndex`, anchor target report action in the mi…
chrispader Apr 23, 2026
a7b13ff
feat: more sophisticated center estimation/calculation
chrispader Apr 23, 2026
1d38351
feat: render in middle with skeleton
chrispader May 11, 2026
162a6ca
fix: don't estimate item height and link to top edge of item
chrispader May 12, 2026
8491644
feat: measure linked item and readjust
chrispader May 12, 2026
2234980
fix: report reloading when message marked unread
chrispader May 12, 2026
8796840
Merge branch 'main' into @chrispader/feat/open-linked-report-action-i…
chrispader May 18, 2026
964537c
fix: types
chrispader May 18, 2026
21104aa
fix: invalid import
chrispader May 18, 2026
e6c2746
Merge branch 'main' into @chrispader/feat/open-linked-report-action-i…
chrispader May 18, 2026
c1e717a
fix: show skeleton until all report actions are loaded
chrispader May 18, 2026
7963fa2
fix: also manually scroll for unread message
chrispader May 18, 2026
1a74492
refactor: extract vertical alignment logic into hook
chrispader May 18, 2026
1915f6a
fix: remove duplicate code
chrispader May 18, 2026
74e9e64
refactor: re-arrange changes to avoid big Git diff
chrispader May 18, 2026
397c7b2
fix: remove duplicate return values
chrispader May 19, 2026
a7c7ba9
fix: invalid hook import name
chrispader May 19, 2026
8d5d951
revert: unnecessary `listID` change
chrispader May 19, 2026
a03e362
test: add unit test for `useVerticallyCenteredInitialContent` hook
chrispader May 19, 2026
28c28cd
fix: skeleton showing when message marked unread
chrispader May 19, 2026
276217c
fix: dep array
chrispader May 19, 2026
9debe7c
test: add another unit test
chrispader May 19, 2026
b3ef35a
fix: upper edge centered
chrispader May 19, 2026
1228e7e
fix: prevent scroll when message is marked unread
chrispader May 19, 2026
7869832
fix: popover flashing when message marked unread
chrispader May 19, 2026
43a4178
fix: lint changes
chrispader May 19, 2026
78687e2
fix: info badge doesn't properly link
chrispader May 19, 2026
3844133
fix: vertically center edge/center based on list height
chrispader May 19, 2026
ea56f74
fix: spell check
chrispader May 19, 2026
b354c37
fix: prefer early return
chrispader May 19, 2026
31e555d
fix: deps
chrispader May 19, 2026
d87e092
refactor: extract logic into multiple files and simplify code
chrispader May 19, 2026
d3b7e1f
temp: rename file for Git
chrispader May 19, 2026
1b53084
temp: rename file for Git
chrispader May 19, 2026
c819596
refactor: rename hook
chrispader May 19, 2026
884269b
fix: invalid import
chrispader May 19, 2026
1dd642d
refactor: remove unused code around `useFlashListScrollKey`
chrispader May 19, 2026
5835e1b
fix: remove unused handling of `GetOlderActions` call
chrispader May 19, 2026
d64e117
fix: reduce `MEASURED_SCROLL_FALLBACK_MS` to 1 sec
chrispader May 19, 2026
fdf02dc
refactor: remove more unused code
chrispader May 19, 2026
4ca212f
fix: typecheck
chrispader May 19, 2026
fc61958
Merge branch 'main' into @chrispader/feat/open-linked-report-action-i…
chrispader May 19, 2026
310fd76
fix: typo
chrispader May 19, 2026
c2170c8
Merge branch 'main' into @chrispader/feat/open-linked-report-action-i…
chrispader May 19, 2026
d207913
revert: unrelated changes
chrispader May 19, 2026
615a57a
revert: unrelated changes
chrispader May 19, 2026
32da57a
docs: add comment for `eslint-disable no-param-reassign`
chrispader May 19, 2026
0ceb25f
fix: cancel `requestAnimationFrame` callbacks
chrispader May 19, 2026
a4d80df
fix: increase `MEASURED_SCROLL_FALLBACK_MS` to 3 seconds
chrispader May 19, 2026
528574a
Merge branch 'main' into @chrispader/feat/open-linked-report-action-i…
chrispader May 21, 2026
46e93ac
fix: wait for measurement + scroll before skeleton is hidden
chrispader May 21, 2026
4eb6b75
fix: absolutely center item
chrispader May 21, 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
3 changes: 2 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,8 @@
"Gclid",
"autocorrection",
"BambooHr",
"HiBob"
"HiBob",
"overscan"
],
"ignorePaths": [
".gitignore",
Expand Down
51 changes: 10 additions & 41 deletions src/components/FlashList/InvertedFlashList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,33 @@
import type {FlashListProps} from '@shopify/flash-list';
import React from 'react';
import useFlashListScrollKey from '@components/FlashList/useFlashListScrollKey';
import type {FlatListRefType} from '@pages/inbox/ReportScreenContext';
import type {FlashListRefType, FlatListRefType} from '@pages/inbox/ReportScreenContext';
import FlashList from '..';
import CellRendererComponent from './CellRendererComponent';

type InvertedFlashListProps<T> = FlashListProps<T> & {
/** Key of the item to initially scroll to when the list first renders. */
initialScrollKey?: string | null;

/** The array of items to render in the list. */
data: T[];

/** Function that extracts a unique key for each item in the list. */
keyExtractor: (item: T, index: number) => string;
/** Key of the item to initially scroll to when the list first renders. */
initialScrollKey?: string | null;

/** Ref to the underlying list instance. */
ref: FlatListRefType;

/** Whether the list should handle `maintainVisibleContentPosition` */
shouldMaintainVisibleContentPosition?: boolean;
ref: FlashListRefType<T> | FlatListRefType<T> | null;
};

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

const maintainVisibleContentPosition = maintainVisibleContentPositionProp
? {
...maintainVisibleContentPositionForScrollKey,
...maintainVisibleContentPositionProp,
}
: maintainVisibleContentPositionForScrollKey;
function InvertedFlashList<T>({data, keyExtractor, initialScrollKey, initialScrollIndex: initialScrollIndexProp, ...restProps}: InvertedFlashListProps<T>) {
const targetIndex = initialScrollKey == null ? -1 : data.findIndex((item, index) => keyExtractor?.(item, index) === initialScrollKey);
const initialScrollIndexForKey = targetIndex < 0 ? undefined : targetIndex;
const initialScrollIndex = initialScrollIndexProp ?? initialScrollIndexForKey;

return (
<FlashList<T>
{...restProps}
inverted
onStartReached={onStartReached}
data={displayedData}
data={data}
keyExtractor={keyExtractor}
initialScrollIndex={initialScrollIndex}
CellRendererComponent={CellRendererComponent}
maintainVisibleContentPosition={maintainVisibleContentPosition}
/>
);
}
Expand Down
64 changes: 0 additions & 64 deletions src/components/FlashList/useFlashListScrollKey.ts

This file was deleted.

3 changes: 2 additions & 1 deletion src/hooks/useReportScrollManager/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type {FlatListRefType} from '@pages/inbox/ReportScreenContext';
import type * as OnyxTypes from '@src/types/onyx';

type ReportScrollManagerData = {
ref: FlatListRefType;
ref: FlatListRefType<OnyxTypes.ReportAction>;
scrollToIndex: (index: number, isEditing?: boolean) => void;
scrollToBottom: () => void;
scrollToEnd: () => void;
Expand Down
9 changes: 6 additions & 3 deletions src/pages/inbox/ReportScreenContext.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type {FlashListRef} from '@shopify/flash-list';
import type {RefObject, SyntheticEvent} from 'react';
import {createContext} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {FlatList, GestureResponderEvent, Text, View} from 'react-native';
import type * as OnyxTypes from '@src/types/onyx';

type ReactionListAnchor = View | Text | HTMLDivElement | null;

Expand All @@ -13,12 +15,13 @@ type ReactionListContextType = {
isActiveReportAction: (reportActionID: number | string) => boolean;
};

type FlatListRefType = RefObject<FlatList<unknown> | null> | null;
type FlatListRefType<T = unknown> = RefObject<FlatList<T> | null> | null;

type FlashListRefType<T = unknown> = RefObject<FlashListRef<T> | null> | null;
type ScrollPosition = {offset?: number};

type ActionListContextType = {
flatListRef: FlatListRefType;
flatListRef: FlatListRefType<OnyxTypes.ReportAction>;
scrollPositionRef: RefObject<ScrollPosition>;
scrollOffsetRef: RefObject<number>;
};
Expand All @@ -31,4 +34,4 @@ const ReactionListContext = createContext<ReactionListContextType>({
});

export {ActionListContext, ReactionListContext};
export type {ReactionListContextType, ActionListContextType, FlatListRefType, ReactionListAnchor, ReactionListEvent, ScrollPosition};
export type {ReactionListContextType, ActionListContextType, FlatListRefType, FlashListRefType, ReactionListAnchor, ReactionListEvent, ScrollPosition};
108 changes: 108 additions & 0 deletions src/pages/inbox/report/InitialViewportUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import CONST from '@src/CONST';

const INITIAL_TARGET_REPORT_ACTION_ESTIMATED_HEIGHT = CONST.CHAT_SKELETON_VIEW.AVERAGE_ROW_HEIGHT;
const INITIAL_VIEWPORT_OVERSCAN_ITEMS = 2;

type InitialViewportRange = {
first: number;
last: number;
requiredMountedItems: number;
};

type InitialViewportResetSession = {
listID: string;
reportID: string;
linkedReportActionID: string | undefined;
initialScrollKey: string | undefined;
};

type MeasuredLinkedRowScrollPosition = {
viewOffset?: number;
viewPosition?: number;
};

function isUnreadMarkerOnlyInitialScrollKeyChange(
previousSession: InitialViewportResetSession | undefined,
listID: string,
reportID: string,
linkedReportActionID: string | undefined,
initialScrollKey: string | undefined,
) {
if (!previousSession || linkedReportActionID) {
return false;
}

const didUnreadMarkerChange = previousSession.initialScrollKey !== initialScrollKey;
const isSameListSession = previousSession.listID === listID && previousSession.reportID === reportID && previousSession.linkedReportActionID === linkedReportActionID;

return didUnreadMarkerChange && isSameListSession;
}

function isInitialViewportCovered(mountedIndices: Set<number>, range: InitialViewportRange, initialScrollIndex: number) {
if (mountedIndices.size < range.requiredMountedItems) {
return false;
Comment on lines +42 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add fallback when initial viewport mount count is under-estimated

This gate requires mountedIndices.size >= requiredMountedItems, where requiredMountedItems is inferred from an average row height. For chats with very tall actions near the anchor, FlashList may mount fewer cells than that estimate, so isInitialViewportCovered never returns true and the full-screen initial skeleton can remain indefinitely. Unlike measured scroll, there is no timeout fallback for this loading gate.

Useful? React with 👍 / 👎.

}

const mountedIndexList = Array.from(mountedIndices);
const hasItemBeforeInitialTarget = range.first >= initialScrollIndex || mountedIndexList.some((index) => index < initialScrollIndex);
const hasItemAfterInitialTarget = range.last <= initialScrollIndex || mountedIndexList.some((index) => index > initialScrollIndex);

return hasItemBeforeInitialTarget && hasItemAfterInitialTarget;
}

/**
* Inverted FlashList `scrollToIndex` with `-listHeight / 2` places the row's bottom edge at mid-viewport.
* Adjust from that baseline to land either the row's top edge or vertical center on mid-viewport.
*/
function getMeasuredLinkedRowScrollViewOffset(listHeight: number, layoutHeight: number) {
const midViewportOffset = -listHeight / 2;

if (layoutHeight > listHeight) {
return midViewportOffset + layoutHeight;
}

return midViewportOffset + layoutHeight / 2;
}

function getMeasuredLinkedRowScrollPosition(listHeight: number, layoutHeight: number): MeasuredLinkedRowScrollPosition {
if (layoutHeight > listHeight) {
return {viewOffset: getMeasuredLinkedRowScrollViewOffset(listHeight, layoutHeight)};
}

return {viewPosition: 0.5};
}

function computeInitialViewportRange(listHeight: number, initialScrollIndex: number, visibleActionCount: number): InitialViewportRange | undefined {
if (listHeight <= 0 || initialScrollIndex < 0) {
return undefined;
}

const estimatedVisibleReportActions = Math.max(1, Math.ceil(listHeight / INITIAL_TARGET_REPORT_ACTION_ESTIMATED_HEIGHT));
const radius = Math.ceil(estimatedVisibleReportActions / 2) + INITIAL_VIEWPORT_OVERSCAN_ITEMS;
const first = Math.max(initialScrollIndex - radius, 0);
const last = Math.min(initialScrollIndex + radius, visibleActionCount - 1);

return {
first,
last,
requiredMountedItems: Math.min(estimatedVisibleReportActions, last - first + 1),
};
}

function findInitialScrollIndex<T>(sortedVisibleReportActions: T[], keyExtractor: (item: T) => string, initialScrollKey: string | undefined) {
if (!initialScrollKey) {
return -1;
}

return sortedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey);
}

export type {InitialViewportRange, InitialViewportResetSession, MeasuredLinkedRowScrollPosition};
export {
computeInitialViewportRange,
findInitialScrollIndex,
getMeasuredLinkedRowScrollPosition,
getMeasuredLinkedRowScrollViewOffset,
isInitialViewportCovered,
isUnreadMarkerOnlyInitialScrollKeyChange,
};
Loading
Loading