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
4 changes: 2 additions & 2 deletions src/components/FlashList/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {FlashListRef} from '@shopify/flash-list';
import type {RefObject} from 'react';
import type {FlatList} from 'react-native';

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

export default FlatListRefType;
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,12 @@ function unifiedListItemType(item: UnifiedListItem) {
}

type MoneyRequestReportFlashListProps = FlashListProps<UnifiedListItem> & {
/** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */
/** Ref to the underlying list, shared via the ActionList context. */
ref: FlatListRefType;
};

/**
* Forwards the shared ActionList context ref to the underlying FlashList. That context slot predates this FlashList-based
* list and is still shared with the legacy report list, so it is typed for a FlatList. Mirroring InvertedFlashList, the
* 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.
*/
Expand Down
12 changes: 11 additions & 1 deletion src/hooks/useReportActionsScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ function useReportActionsScroll({
setTreatAsNoPaginationAnchor,
}: UseReportActionsScrollParams): UseReportActionsScrollResult {
const reportScrollManager = useReportScrollManager();
const {scrollOffsetRef} = useActionListContext();
const {scrollOffsetRef, getListRef} = useActionListContext();
const {windowHeight} = useWindowDimensions();
const route = useRoute<PlatformStackRouteProp<ReportsSplitNavigatorParamList, typeof SCREENS.REPORT>>();
const linkedReportActionID = route?.params?.reportActionID;
Expand Down Expand Up @@ -243,6 +243,16 @@ function useReportActionsScroll({
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
},
actionBadgeTargetIndex,
getVisibleIndexRange: () => {
const list = getListRef()?.current;
// getLayout is the one accessor that does not throw before FlashList has laid out
if (!list?.getLayout(0)) {
return undefined;
}
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;
},
shouldBeAlignedToTop,
});

Expand Down
6 changes: 1 addition & 5 deletions src/hooks/useReportScrollManager/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import {useActionListContext} from '@pages/inbox/ActionListContext';

import type {ComponentRef} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {ScrollView} from 'react-native';

import type {ReportScrollManagerData, ScrollToIndexOptions} from './types';

function useReportScrollManager(): ReportScrollManagerData {
Expand Down Expand Up @@ -44,7 +40,7 @@ function useReportScrollManager(): ReportScrollManagerData {
return;
}

const scrollViewRef = listRef.current.getNativeScrollRef?.() as ComponentRef<typeof ScrollView> | undefined;
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
4 changes: 2 additions & 2 deletions src/pages/inbox/ActionListContext.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type FlatListRefType from '@components/FlashList/types';

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

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

Expand Down Expand Up @@ -35,7 +35,7 @@ function useActionListContext() {
*/
function useActionListRef() {
const {registerListRef} = useActionListContext();
const listRef = useRef<FlatList>(null);
const listRef = useRef<FlashListRef<unknown>>(null);

useLayoutEffect(() => {
registerListRef(listRef);
Expand Down
14 changes: 13 additions & 1 deletion src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ type Args = {
/** The index of the action badge target report action in the sorted visible actions list (-1 if none) */
actionBadgeTargetIndex?: number;

/** Returns the list's visible index range as of now, or undefined before the list has a layout */
getVisibleIndexRange?: () => {startIndex: number; endIndex: number} | undefined;

/** Whether the report is aligned to the top. When true, the "Latest messages" pill should never be shown. */
shouldBeAlignedToTop?: boolean;

Expand All @@ -46,6 +49,7 @@ export default function useReportUnreadMessageScrollTracking({
unreadMarkerReportActionIndex,
isInverted,
actionBadgeTargetIndex = -1,
getVisibleIndexRange,
shouldBeAlignedToTop = false,
shouldDisablePillTracking = false,
}: Args) {
Expand All @@ -59,13 +63,15 @@ export default function useReportUnreadMessageScrollTracking({
isFocused: boolean;
onUnreadActionVisible: () => void;
actionBadgeTargetIndex: number;
getVisibleIndexRange: Args['getVisibleIndexRange'];
}>({
reportID,
unreadMarkerReportActionIndex,
previousViewableItems: [],
isFocused: true,
onUnreadActionVisible,
actionBadgeTargetIndex,
getVisibleIndexRange,
});
// We want to save the updated value on ref to use it in onViewableItemsChanged
// because FlatList requires the callback to be stable and we cannot add a dependency on the useCallback.
Expand All @@ -82,6 +88,10 @@ export default function useReportUnreadMessageScrollTracking({
ref.current.onUnreadActionVisible = onUnreadActionVisible;
}, [onUnreadActionVisible]);

useEffect(() => {
ref.current.getVisibleIndexRange = getVisibleIndexRange;
}, [getVisibleIndexRange]);

/**
* Show/hide the latest message pill when user is scrolling back/forth in the history of messages.
*/
Expand Down Expand Up @@ -163,7 +173,9 @@ export default function useReportUnreadMessageScrollTracking({
if (badgeTargetIndex !== -1) {
// In an inverted list, higher indexes are "above" (older messages). The target is above the viewport
// when its index is greater than the max visible index.
const isAbove = isInverted ? badgeTargetIndex > maxIndex : badgeTargetIndex < minIndex;
// FlashList reports a snapshot taken up to 250ms earlier, which can predate the new row's measured layout, so prefer the live range
const visibleRange = ref.current.getVisibleIndexRange?.();
const isAbove = isInverted ? badgeTargetIndex > (visibleRange?.endIndex ?? maxIndex) : badgeTargetIndex < (visibleRange?.startIndex ?? minIndex);
setIsActionBadgeAboveViewport(isAbove);
} else {
setIsActionBadgeAboveViewport(false);
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/useReportUnreadMessageScrollTrackingTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,5 +494,38 @@ describe('useReportUnreadMessageScrollTracking', () => {

expect(result.current.isActionBadgeAboveViewport).toBe(true);
});

it('keeps the action badge hidden when the live range shows the target despite stale viewable items', () => {
// Given a live range that contains the target, as after a sent message shifts it from index 2 to 3
const offsetRef = {current: 0};
const {result} = renderHook(() =>
useReportUnreadMessageScrollTracking({
reportID,
currentVerticalScrollingOffsetRef: offsetRef,
onUnreadActionVisible: onUnreadActionVisibleMockFn,
onTrackScrolling: onTrackScrollingMockFn,
hasNewerActions: false,
unreadMarkerReportActionIndex: -1,
isInverted: true,
actionBadgeTargetIndex: 3,
getVisibleIndexRange: () => ({startIndex: 0, endIndex: 3}),
}),
);

// When FlashList reports a snapshot taken before the shift, which ends at index 2
act(() => {
result.current.onViewableItemsChanged({
viewableItems: [
{index: 0, key: 'reportActions_0', isViewable: true, item: {}},
{index: 1, key: 'reportActions_1', isViewable: true, item: {}},
{index: 2, key: 'reportActions_2', isViewable: true, item: {}},
],
changed: [],
});
});

// Then the live range wins and the pill does not flash
expect(result.current.isActionBadgeAboveViewport).toBe(false);
});
});
});
Loading