From 75ed92483003bc08bd4be12e158109453d551205 Mon Sep 17 00:00:00 2001
From: Jan Ole Schmidt <jan.ole.schmidt@holi.team>
Date: Wed, 29 Jan 2025 10:17:18 +0000
Subject: [PATCH] HOLI-10877: implement scroll back to top button on home feed

---
 core/screens/homeFeed/Feed.tsx             |   8 +-
 core/tracking/TrackableFlatList.shared.tsx |   6 +-
 core/tracking/TrackableFlatList.tsx        | 218 +++++++++++----------
 core/tracking/TrackableFlatList.web.tsx    |  20 +-
 4 files changed, 140 insertions(+), 112 deletions(-)

diff --git a/core/screens/homeFeed/Feed.tsx b/core/screens/homeFeed/Feed.tsx
index be2ad0d6d3..f77803ef1a 100644
--- a/core/screens/homeFeed/Feed.tsx
+++ b/core/screens/homeFeed/Feed.tsx
@@ -1,5 +1,5 @@
 import { withProfiler } from '@sentry/react'
-import React, { memo, useContext, useMemo, useCallback } from 'react'
+import React, { memo, useContext, useMemo, useCallback, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import { StyleSheet, View, RefreshControl } from 'react-native'
 import { GestureHandlerRootView } from 'react-native-gesture-handler'
@@ -34,6 +34,7 @@ import type { VolunteeringReco } from '@holi-apps/volunteering/types'
 import VolunteeringCard from '@holi-apps/volunteering/components/VolunteeringCard'
 import { useFeedQuery } from '@holi/core/screens/homeFeed/useFeedQuery'
 import InsightCard from '@holi/core/screens/insights/components/InsightCard'
+import { useScrollToTop } from '@react-navigation/native'
 
 const surveyUrl = 'https://tally.so/r/nrdGxX'
 
@@ -48,6 +49,8 @@ const HomeFeed = () => {
   const { initialLoading, canLoadMore, combinedFeedData, fetchMore, refetch, refreshing, insightsCreators } =
     useFeedQuery()
 
+  const ref = useRef(null)
+
   const keyExtractor = useCallback(
     (
       item: FeedItem & {
@@ -140,6 +143,8 @@ const HomeFeed = () => {
     )
   }
 
+  useScrollToTop(ref)
+
   useIsomorphicLayoutEffect(() => {
     setScreenOptions({
       title: '',
@@ -209,6 +214,7 @@ const HomeFeed = () => {
               trackOnce={true}
               withFlashList={false}
               estimatedItemSize={260}
+              ref={ref}
             />
           </ErrorBoundary>
         </View>
diff --git a/core/tracking/TrackableFlatList.shared.tsx b/core/tracking/TrackableFlatList.shared.tsx
index 7fbdf3a769..7f6557a82c 100644
--- a/core/tracking/TrackableFlatList.shared.tsx
+++ b/core/tracking/TrackableFlatList.shared.tsx
@@ -1,7 +1,9 @@
-import type { FlatListProps } from 'react-native'
+import type { FlatList, FlatListProps } from 'react-native'
 
 import type { TrackingEvent } from '@holi/core/tracking/events'
 
+import type { FlashList } from '@shopify/flash-list'
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type TrackableFlatListItem = any
 
@@ -25,6 +27,8 @@ export type TrackableFlatListParams = {
 
   // a flag that defines whether an item should be tracked only once or every time it's viewed
   trackOnce?: boolean
+
+  ref?: React.Ref<FlatList<TrackableFlatListItem> | FlashList<TrackableFlatListItem>>
 }
 
 export type TrackableFlatListProps = FlatListProps<TrackableFlatListItem> &
diff --git a/core/tracking/TrackableFlatList.tsx b/core/tracking/TrackableFlatList.tsx
index 8524b39e25..8feffd71b4 100644
--- a/core/tracking/TrackableFlatList.tsx
+++ b/core/tracking/TrackableFlatList.tsx
@@ -1,5 +1,5 @@
 import type React from 'react'
-import { useCallback, useRef, useState } from 'react'
+import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'
 import { FlatList, type LayoutChangeEvent } from 'react-native'
 import { FlashList, type FlashListProps } from '@shopify/flash-list'
 import ScrollBasedCallbackInvocation from '@holi/core/components/ScrollBasedCallbackInvocation'
@@ -11,124 +11,134 @@ import {
 } from '@holi/core/tracking/TrackableFlatList.shared'
 import useTracking from '@holi/core/tracking/hooks/useTracking'
 
-const TrackableFlatList = ({
-  itemVisiblePercentThreshold,
-  minimumViewTime,
-  listItemTrackingEvent,
-  listItemIdentifier,
-  trackOnce,
-  withFlashList = false,
-  estimatedItemSize,
-  ...restProps
-}: TrackableFlatListProps) => {
-  const { track } = useTracking()
-
-  const list = useRef<FlatList | FlashList<TrackableFlatListItem>>(null)
-
-  const [listBelowFoldThreshold, setListBelowFoldThreshold] = useState(0)
-  const [isListTrackingInitialized, setIsListTrackingInitialized] = useState(false)
-
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  const [_, setTrackedItems] = useState<Record<string, boolean>>({})
+const TrackableFlatList = forwardRef(
+  (
+    {
+      itemVisiblePercentThreshold,
+      minimumViewTime,
+      listItemTrackingEvent,
+      listItemIdentifier,
+      trackOnce,
+      withFlashList = false,
+      estimatedItemSize,
+      ...restProps
+    }: TrackableFlatListProps,
+    ref
+  ) => {
+    const { track } = useTracking()
+
+    const listRef = useRef<FlatList | FlashList<TrackableFlatListItem>>(null)
+
+    // Expose the listRef to the parent component to allow for scrollToTop
+    useImperativeHandle(ref, () => listRef.current as FlatList | FlashList<TrackableFlatListItem>)
+
+    const [listBelowFoldThreshold, setListBelowFoldThreshold] = useState(0)
+    const [isListTrackingInitialized, setIsListTrackingInitialized] = useState(false)
+
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const [_, setTrackedItems] = useState<Record<string, boolean>>({})
+
+    const trackItem = (item: TrackableFlatListItem) => {
+      track(listItemTrackingEvent(item))
+    }
 
-  const trackItem = (item: TrackableFlatListItem) => {
-    track(listItemTrackingEvent(item))
-  }
+    const viewabilityConfig: CustomViewabilityConfig = {
+      ...defaultViewabilityConfig,
+      minimumViewTime: minimumViewTime ?? defaultViewabilityConfig.minimumViewTime,
+      itemVisiblePercentThreshold: itemVisiblePercentThreshold ?? defaultViewabilityConfig.itemVisiblePercentThreshold,
+    }
 
-  const viewabilityConfig: CustomViewabilityConfig = {
-    ...defaultViewabilityConfig,
-    minimumViewTime: minimumViewTime ?? defaultViewabilityConfig.minimumViewTime,
-    itemVisiblePercentThreshold: itemVisiblePercentThreshold ?? defaultViewabilityConfig.itemVisiblePercentThreshold,
-  }
+    const onViewableItemsChanged: TrackableFlatListProps['onViewableItemsChanged'] = (info) => {
+      // Filter out "changed" elements that are not visible on the screen AND have been already tracked
+      const visibleItems = info.changed.filter((entry) => entry.isViewable)
 
-  const onViewableItemsChanged: TrackableFlatListProps['onViewableItemsChanged'] = (info) => {
-    // Filter out "changed" elements that are not visible on the screen AND have been already tracked
-    const visibleItems = info.changed.filter((entry) => entry.isViewable)
+      // Early return if no new viewed elements available
+      if (!visibleItems.length) return
 
-    // Early return if no new viewed elements available
-    if (!visibleItems.length) return
+      // If "trackOnce" is false, we loop through all "visibleItems" and trigger tracking callback for all array elements
+      if (!trackOnce) {
+        visibleItems.forEach(({ item }) => trackItem(item))
+        return
+      }
 
-    // If "trackOnce" is false, we loop through all "visibleItems" and trigger tracking callback for all array elements
-    if (!trackOnce) {
-      visibleItems.forEach(({ item }) => trackItem(item))
-      return
-    }
+      // If "trackOnce" is true, we save already tracked items and trigger tracking callback only for the new ones
+      setTrackedItems((prevTrackedItems) => {
+        const newVisibleItems = visibleItems.reduce((acc, { item }) => {
+          const id = listItemIdentifier(item)
 
-    // If "trackOnce" is true, we save already tracked items and trigger tracking callback only for the new ones
-    setTrackedItems((prevTrackedItems) => {
-      const newVisibleItems = visibleItems.reduce((acc, { item }) => {
-        const id = listItemIdentifier(item)
+          // Send the tracking event for the item if it hasn't been tracked yet
+          if (!prevTrackedItems[item[id]]) {
+            trackItem(item)
+          }
 
-        // Send the tracking event for the item if it hasn't been tracked yet
-        if (!prevTrackedItems[item[id]]) {
-          trackItem(item)
-        }
+          return {
+            ...acc,
+            [item[id]]: true,
+          }
+        }, {})
 
+        // Update state with the new tracked items
         return {
-          ...acc,
-          [item[id]]: true,
+          ...prevTrackedItems,
+          ...newVisibleItems,
         }
-      }, {})
+      })
+    }
 
-      // Update state with the new tracked items
-      return {
-        ...prevTrackedItems,
-        ...newVisibleItems,
-      }
-    })
-  }
+    // To precisely know when to trigger "onListIsInView", we need to calculate "belowFoldThreshold".
+    // "itemVisiblePercentThreshold" should be considered while calculating this value.
+    const onListLayout = (event: LayoutChangeEvent) => {
+      const listHeight = event.nativeEvent.layout.height
+      const itemVisiblePercentThreshold = viewabilityConfig.itemVisiblePercentThreshold
+      const listBelowFoldThreshold = Math.round((listHeight * itemVisiblePercentThreshold) / 100)
 
-  // To precisely know when to trigger "onListIsInView", we need to calculate "belowFoldThreshold".
-  // "itemVisiblePercentThreshold" should be considered while calculating this value.
-  const onListLayout = (event: LayoutChangeEvent) => {
-    const listHeight = event.nativeEvent.layout.height
-    const itemVisiblePercentThreshold = viewabilityConfig.itemVisiblePercentThreshold
-    const listBelowFoldThreshold = Math.round((listHeight * itemVisiblePercentThreshold) / 100)
+      setListBelowFoldThreshold(listBelowFoldThreshold)
+    }
 
-    setListBelowFoldThreshold(listBelowFoldThreshold)
-  }
+    // By default, "onViewableItemsChanged" is triggered on first render for all initially visible elements.
+    // To prevent that, we need to wait until the list actually appears on the screen for the first time.
+    // After tracking is initialized, we can hide ScrollBasedCallbackInvocation to avoid unnecessary scroll event listeners.
+    const onListIsInView = () => {
+      listRef.current?.recordInteraction()
+      setIsListTrackingInitialized(true)
+    }
 
-  // By default, "onViewableItemsChanged" is triggered on first render for all initially visible elements.
-  // To prevent that, we need to wait until the list actually appears on the screen for the first time.
-  // After tracking is initialized, we can hide ScrollBasedCallbackInvocation to avoid unnecessary scroll event listeners.
-  const onListIsInView = () => {
-    list.current?.recordInteraction()
-    setIsListTrackingInitialized(true)
+    // According to RN, the following callback cannot be updated after the initial render, therefore we keep deps as []
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    const onViewableItemsChangedCallback = useCallback(onViewableItemsChanged, [onViewableItemsChanged])
+
+    return (
+      <>
+        {!isListTrackingInitialized ? (
+          <ScrollBasedCallbackInvocation
+            callback={onListIsInView}
+            belowFoldThreshold={listBelowFoldThreshold}
+            placeholder={<></>}
+          />
+        ) : null}
+        {withFlashList ? (
+          <FlashList
+            {...(restProps as FlashListProps<TrackableFlatListItem>)}
+            ref={listRef as React.RefObject<FlashList<TrackableFlatListItem>>}
+            onLayout={onListLayout}
+            viewabilityConfig={viewabilityConfig}
+            onViewableItemsChanged={onViewableItemsChangedCallback}
+            estimatedItemSize={estimatedItemSize}
+          />
+        ) : (
+          <FlatList
+            {...restProps}
+            ref={listRef as React.RefObject<FlatList<TrackableFlatListItem>>}
+            onLayout={onListLayout}
+            viewabilityConfig={viewabilityConfig}
+            onViewableItemsChanged={onViewableItemsChangedCallback}
+          />
+        )}
+      </>
+    )
   }
+)
 
-  // According to RN, the following callback cannot be updated after the initial render, therefore we keep deps as []
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  const onViewableItemsChangedCallback = useCallback(onViewableItemsChanged, [])
-
-  return (
-    <>
-      {!isListTrackingInitialized ? (
-        <ScrollBasedCallbackInvocation
-          callback={onListIsInView}
-          belowFoldThreshold={listBelowFoldThreshold}
-          placeholder={<></>}
-        />
-      ) : null}
-      {withFlashList ? (
-        <FlashList
-          {...(restProps as FlashListProps<TrackableFlatListItem>)}
-          ref={list as React.RefObject<FlashList<TrackableFlatListItem>>}
-          onLayout={onListLayout}
-          viewabilityConfig={viewabilityConfig}
-          onViewableItemsChanged={onViewableItemsChangedCallback}
-          estimatedItemSize={estimatedItemSize}
-        />
-      ) : (
-        <FlatList
-          {...restProps}
-          ref={list as React.RefObject<FlatList>}
-          onLayout={onListLayout}
-          viewabilityConfig={viewabilityConfig}
-          onViewableItemsChanged={onViewableItemsChangedCallback}
-        />
-      )}
-    </>
-  )
-}
+TrackableFlatList.displayName = 'TrackableFlatList'
 
 export default TrackableFlatList
diff --git a/core/tracking/TrackableFlatList.web.tsx b/core/tracking/TrackableFlatList.web.tsx
index ff1335ff3f..964aa51bb6 100644
--- a/core/tracking/TrackableFlatList.web.tsx
+++ b/core/tracking/TrackableFlatList.web.tsx
@@ -1,10 +1,10 @@
-import React, { type PropsWithChildren, ReactNode } from 'react'
-import { IntersectionObserverProps, useInView } from 'react-intersection-observer'
-import { FlatList, ListRenderItem } from 'react-native'
+import React, { type PropsWithChildren, type ReactNode, useRef } from 'react'
+import { type IntersectionObserverProps, useInView } from 'react-intersection-observer'
+import { FlatList, type ListRenderItem } from 'react-native'
 
 import {
-  TrackableFlatListItem as TrackableFlatListItemType,
-  TrackableFlatListProps,
+  type TrackableFlatListItem as TrackableFlatListItemType,
+  type TrackableFlatListProps,
   defaultViewabilityConfig,
 } from '@holi/core/tracking/TrackableFlatList.shared'
 import useTracking from '@holi/core/tracking/hooks/useTracking'
@@ -47,6 +47,8 @@ const TrackableFlatList = ({
   renderItem,
   ...restProps
 }: TrackableFlatListProps) => {
+  const flatListRef = useRef<FlatList<TrackableFlatListItemType>>(null)
+
   const { track } = useTracking()
 
   const onInView = (item: TrackableFlatListItemType) => {
@@ -65,7 +67,13 @@ const TrackableFlatList = ({
     </TrackableFlatListItem>
   )
 
-  return <FlatList {...restProps} renderItem={extendedRenderItem} />
+  return (
+    <FlatList
+      {...restProps}
+      ref={flatListRef}
+      renderItem={extendedRenderItem as ListRenderItem<TrackableFlatListItemType>}
+    />
+  )
 }
 
 export default TrackableFlatList
-- 
GitLab