diff --git a/core/screens/homeFeed/Feed.tsx b/core/screens/homeFeed/Feed.tsx index f77803ef1a22666f417aadb2fe6e6e8b1dda87e0..9cb0947dcf4e75cd8b9387ba44fd71541cf4f0bc 100644 --- a/core/screens/homeFeed/Feed.tsx +++ b/core/screens/homeFeed/Feed.tsx @@ -1,8 +1,9 @@ import { withProfiler } from '@sentry/react' -import React, { memo, useContext, useMemo, useCallback, useRef } from 'react' +import React, { memo, useContext, useMemo, useCallback, useRef, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { StyleSheet, View, RefreshControl } from 'react-native' +import { StyleSheet, View, RefreshControl, AppState } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { useNavigation } from '@react-navigation/native' import ErrorBoundary from '@holi/core/errors/components/ErrorBoundary' import useIsomorphicLayoutEffect from '@holi/core/helpers/useIsomorphicLayoutEffect' @@ -35,6 +36,8 @@ import VolunteeringCard from '@holi-apps/volunteering/components/VolunteeringCar import { useFeedQuery } from '@holi/core/screens/homeFeed/useFeedQuery' import InsightCard from '@holi/core/screens/insights/components/InsightCard' import { useScrollToTop } from '@react-navigation/native' +import useTracking from '@holi/core/tracking/hooks/useTracking' +import { useFeedStats } from '@holi/core/screens/homeFeed/helpers/useFeedStats' const surveyUrl = 'https://tally.so/r/nrdGxX' @@ -43,12 +46,18 @@ const HomeFeed = () => { const { theme } = useTheme() const colors = useMemo(() => theme.colors, [theme]) const { navigate } = useRouting() + const [lastViewedIndex, setLastViewedIndex] = useState(0) const setScreenOptions = useSetScreenOptions() + const updateLastViewedIndex = useCallback((index: number) => { + setLastViewedIndex((prev) => Math.max(prev, index)) + }, []) const displayFeedbackLink = useFeatureFlagWithMaintenance(FeatureFlagKey.FEEDBACK_LINK).isOn const { topics: allTopics } = useContext(SpaceTermContext) const { initialLoading, canLoadMore, combinedFeedData, fetchMore, refetch, refreshing, insightsCreators } = useFeedQuery() + const { track } = useTracking() + const navigation = useNavigation() const ref = useRef(null) const keyExtractor = useCallback( @@ -79,15 +88,18 @@ const HomeFeed = () => { goodNewsArticle?: GoodNewsArticle volunteeringReco?: VolunteeringReco } - }) => ( - <HoliTransition.FadeDown visible> - {item.feedPost && <GenericPostCard post={item.feedPost} allTopics={allTopics} />} - {item.spacePost && <GenericPostCard post={item.spacePost} allTopics={allTopics} />} - {item.insight && insightsCreators && <InsightCard insight={item.insight} creators={insightsCreators} />} - {item.goodNewsArticle && <GoodNewsCard article={item.goodNewsArticle} />} - {item.volunteeringReco && <VolunteeringCard recommendation={item.volunteeringReco} />} - </HoliTransition.FadeDown> - ), + index: number + }) => { + return ( + <HoliTransition.FadeDown visible> + {item.feedPost && <GenericPostCard post={item.feedPost} allTopics={allTopics} />} + {item.spacePost && <GenericPostCard post={item.spacePost} allTopics={allTopics} />} + {item.insight && insightsCreators && <InsightCard insight={item.insight} creators={insightsCreators} />} + {item.goodNewsArticle && <GoodNewsCard article={item.goodNewsArticle} />} + {item.volunteeringReco && <VolunteeringCard recommendation={item.volunteeringReco} />} + </HoliTransition.FadeDown> + ) + }, [allTopics, insightsCreators] ) @@ -173,6 +185,25 @@ const HomeFeed = () => { }) }, [navigate, t, setScreenOptions, colors.black300, displayFeedbackLink]) + const getFeedStats = useFeedStats(combinedFeedData, lastViewedIndex, track) + + useEffect(() => { + const navigationUnsubscribe = navigation.addListener('blur', () => { + getFeedStats() + }) + + const appStateSubscription = AppState.addEventListener('change', (nextAppState) => { + if (nextAppState === 'background' || nextAppState === 'inactive') { + getFeedStats() + } + }) + + return () => { + navigationUnsubscribe() + appStateSubscription.remove() + } + }, [navigation, getFeedStats]) + if (initialLoading) return ( <View style={[styles.wrapper, { backgroundColor: colors.white200 }]}> @@ -208,13 +239,13 @@ const HomeFeed = () => { listItemTrackingEvent={listItemTrackingEvent} listItemIdentifier={listItemIdentifier} ItemSeparatorComponent={() => <View style={{ height: Spacing.sm }} />} - itemVisiblePercentThreshold={80} minimumViewTime={1000} showsVerticalScrollIndicator={false} trackOnce={true} withFlashList={false} estimatedItemSize={260} ref={ref} + onLastViewedIndexChange={updateLastViewedIndex} /> </ErrorBoundary> </View> diff --git a/core/screens/homeFeed/Feed.web.tsx b/core/screens/homeFeed/Feed.web.tsx index 65a0bdb9a9d4eb2c1aaceb6a8f0c9569adca7b3d..9fe332f9f2a030976c08b653812fbdfa37adc7f8 100644 --- a/core/screens/homeFeed/Feed.web.tsx +++ b/core/screens/homeFeed/Feed.web.tsx @@ -1,5 +1,5 @@ import { withProfiler } from '@sentry/react' -import React, { type LegacyRef, memo, useContext, useEffect, useMemo } from 'react' +import React, { memo, useContext, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet, View } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' @@ -36,9 +36,78 @@ 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 useTracking from '@holi/core/tracking/hooks/useTracking' + +import type { User } from '@holi/core/domain/shared/types' +import type { Topic } from '@holi/core/domain/shared/types' +import { useFeedStats } from '@holi/core/screens/homeFeed/helpers/useFeedStats' const surveyUrl = 'https://tally.so/r/nrdGxX' +const FeedListItem = memo( + ({ + item, + index, + columnIndex, + allTopics, + insightsCreators, + onItemView, + loadMoreRef, + }: { + item: FeedItem & { loader?: boolean } & { goodNewsArticle?: GoodNewsArticle } & { + volunteeringReco?: VolunteeringReco + } + index: number + columnIndex: number + allTopics: readonly Topic[] + insightsCreators: User[] + onItemView: (index: number) => void + loadMoreRef?: ((node?: Element | null | undefined) => void) | undefined + }) => { + const { ref } = useInView({ + onChange: (inView) => { + if (inView) { + onItemView(index) + } + }, + threshold: 0.5, + }) + + return ( + <div ref={ref}> + <View + key={ + item.feedPost?.id || + item.goodNewsArticle?.id || + item.volunteeringReco?.engagement.id || + item.insight?.id || + `${index}-${columnIndex}` + } + > + {item.feedPost && <GenericPostCard key={item.feedPost.id} post={item.feedPost} allTopics={allTopics} />} + {item.spacePost && <GenericPostCard key={item.spacePost.id} post={item.spacePost} allTopics={allTopics} />} + {item.insight && insightsCreators && ( + <InsightCard key={item.insight.id} insight={item.insight} creators={insightsCreators} /> + )} + {item.goodNewsArticle && <GoodNewsCard article={item.goodNewsArticle} key={item.goodNewsArticle.id} />} + {item.volunteeringReco && ( + <VolunteeringCard key={item.volunteeringReco.engagement.id} recommendation={item.volunteeringReco} /> + )} + {item.loader && loadMoreRef && ( + <div ref={loadMoreRef}> + <View testID="load-more-ref" style={styles.skeleton} key={`${item.loader}-${columnIndex}-${index}`}> + <HoliSkeleton height={420} width="100%" borderRadius={20} /> + </View> + </div> + )} + </View> + </div> + ) + } +) + +FeedListItem.displayName = 'FeedListItem' + const HomeFeed = () => { const { t } = useTranslation() const { theme } = useTheme() @@ -49,6 +118,8 @@ const HomeFeed = () => { const setScreenOptions = useSetScreenOptions() const displayFeedbackLink = useFeatureFlagWithMaintenance(FeatureFlagKey.FEEDBACK_LINK).isOn const { isLoading, canLoadMore, combinedFeedData, fetchMore, initialLoading, insightsCreators } = useFeedQuery() + const [lastViewedIndex, setLastViewedIndex] = useState(0) + const { track } = useTracking() const loaders = new Array(2).fill({ loader: true }) @@ -99,6 +170,20 @@ const HomeFeed = () => { const dataByColumns = simpleColumnSortingByIndex([...combinedFeedData, ...loaders], numberOfColumns) const animKey = initialLoading ? 'loading' : combinedFeedData.length > 0 ? 'ready' : 'empty' + // Add tracking stats function + const getFeedStats = useFeedStats(combinedFeedData, lastViewedIndex, track) + + // Track when user leaves the page + useEffect(() => { + return () => { + getFeedStats() // Track when component unmounts + } + }, [getFeedStats]) + + const updateLastViewedIndex = (index: number) => { + setLastViewedIndex((prev) => Math.max(prev, index)) + } + return ( <SpaceTermContextProvider> <GestureHandlerRootView style={{ backgroundColor: colors.white200, flex: 1 }} testID="home-feed-listing"> @@ -116,75 +201,21 @@ const HomeFeed = () => { <View style={styles.container} testID="posts-list-results"> {dataByColumns.map((column, columnIndex) => ( <View key={columnIndex} style={styles.column}> - {column.map( - ( - item: FeedItem & { loader?: boolean } & { goodNewsArticle?: GoodNewsArticle } & { - volunteeringReco?: VolunteeringReco - }, - index: number - ) => - item.loader && !canLoadMore ? ( - <></> - ) : ( - <HoliTransition.FadeDown - key={ - item.feedPost?.id || - item.goodNewsArticle?.id || - item.volunteeringReco?.engagement.id || - item.insight?.id || - `${index}-${columnIndex}` - } - visible - index={index} - > - {item.feedPost && ( - <GenericPostCard key={item.feedPost.id} post={item.feedPost} allTopics={allTopics} /> - )} - {item.spacePost && ( - <GenericPostCard - key={item.spacePost.id} - post={item.spacePost} - allTopics={allTopics} - /> - )} - {item.insight && insightsCreators && ( - <InsightCard - key={item.insight.id} - insight={item.insight} - creators={insightsCreators} - /> - )} - {item.goodNewsArticle && ( - <GoodNewsCard article={item.goodNewsArticle} key={item.goodNewsArticle.id} /> - )} - {item.volunteeringReco && ( - <VolunteeringCard - key={item.volunteeringReco.engagement.id} - recommendation={item.volunteeringReco} - /> - )} - {item.loader && columnIndex === 0 && ( - <View - testID="load-more-ref" - ref={loadMoreRef1 as LegacyRef<View>} - style={styles.skeleton} - key={`${item.loader}-${columnIndex}-${index}`} - > - <HoliSkeleton height={420} width="100%" borderRadius={20} /> - </View> - )} - {item.loader && columnIndex === 1 && ( - <View - testID="load-more-ref" - ref={loadMoreRef2 as LegacyRef<View>} - style={styles.skeleton} - key={`${item.loader}-${columnIndex}-${index}`} - > - <HoliSkeleton height={420} width="100%" borderRadius={20} /> - </View> - )} - </HoliTransition.FadeDown> - ) + {column.map((item, index) => + item.loader && !canLoadMore ? ( + <></> + ) : ( + <FeedListItem + key={index} + item={item} + index={numberOfColumns === 1 ? index : index * numberOfColumns + columnIndex} + columnIndex={columnIndex} + allTopics={allTopics} + insightsCreators={insightsCreators} + onItemView={updateLastViewedIndex} + loadMoreRef={item.loader ? (columnIndex === 0 ? loadMoreRef1 : loadMoreRef2) : undefined} + /> + ) )} </View> ))} diff --git a/core/screens/homeFeed/helpers/useFeedStats.ts b/core/screens/homeFeed/helpers/useFeedStats.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee1e9017bca0d1fb2066dde97332bcecafc86bde --- /dev/null +++ b/core/screens/homeFeed/helpers/useFeedStats.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react' +import { TrackingEvent } from '@holi/core/tracking' +import type { FeedItem } from '@holi/core/screens/homeFeed/types' +import type { VolunteeringReco } from '@holi-apps/volunteering/types' +import type { GoodNewsArticle } from '@holi-apps/goodnews/types' + +export const useFeedStats = ( + combinedFeedData: (FeedItem & { + goodNewsArticle?: GoodNewsArticle + volunteeringReco?: VolunteeringReco + })[], + lastViewedIndex: number, + track: (event: TrackingEvent) => void +) => { + const getFeedStats = useCallback(() => { + const viewedItems = combinedFeedData.slice(0, lastViewedIndex + 1) + + const stats = viewedItems.reduce( + (acc, item) => ({ + feedPosts: acc.feedPosts + (item.feedPost ? 1 : 0), + spacePosts: acc.spacePosts + (item.spacePost ? 1 : 0), + insights: acc.insights + (item.insight ? 1 : 0), + goodNews: acc.goodNews + (item.goodNewsArticle ? 1 : 0), + volunteering: acc.volunteering + (item.volunteeringReco ? 1 : 0), + total: acc.total + 1, + }), + { + feedPosts: 0, + spacePosts: 0, + insights: 0, + goodNews: 0, + volunteering: 0, + total: 0, + } + ) + + track( + TrackingEvent.FeedPosts.viewSummary( + stats.total, + stats.feedPosts, + stats.spacePosts, + stats.goodNews, + stats.insights, + stats.volunteering + ) + ) + }, [combinedFeedData, lastViewedIndex, track]) + + return getFeedStats +} diff --git a/core/screens/individualPosts/components/ImagePreview.tsx b/core/screens/individualPosts/components/ImagePreview.tsx index 92c817dfe478cb985883494fdcccb9641bf366c0..9816b7d905f00faad7184c4a15a42c59855b44fb 100644 --- a/core/screens/individualPosts/components/ImagePreview.tsx +++ b/core/screens/individualPosts/components/ImagePreview.tsx @@ -25,9 +25,9 @@ const ImagePreview = ({ style, image, currentImage, blurhash, aspectRatio = 16 / const styles = createStyles(theme, aspectRatio) return ( - <> + <View> {!imageError && isLoading && ( - <View style={styles.image}> + <View style={[styles.image, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }]}> <HoliSkeleton width={'100%'} height={'100%'} borderRadius={Spacing['3xs']} /> </View> )} @@ -68,7 +68,7 @@ const ImagePreview = ({ style, image, currentImage, blurhash, aspectRatio = 16 / )} </View> )} - </> + </View> ) } diff --git a/core/tracking/TrackableFlatList.shared.tsx b/core/tracking/TrackableFlatList.shared.tsx index 7f6557a82c1e452ba4b9b3b3c72b3ec9f6eb56ce..dcc7fb434b4dca74f57781a19596d41d1181c32c 100644 --- a/core/tracking/TrackableFlatList.shared.tsx +++ b/core/tracking/TrackableFlatList.shared.tsx @@ -35,6 +35,7 @@ export type TrackableFlatListProps = FlatListProps<TrackableFlatListItem> & TrackableFlatListParams & { withFlashList?: boolean estimatedItemSize?: number + onLastViewedIndexChange?: (index: number) => void } export type CustomViewabilityConfig = { diff --git a/core/tracking/TrackableFlatList.tsx b/core/tracking/TrackableFlatList.tsx index 8feffd71b445f215309325681cf720434bd4e556..b190c4bc9c1bf0ac9e303dfcfbc075c52920eb6c 100644 --- a/core/tracking/TrackableFlatList.tsx +++ b/core/tracking/TrackableFlatList.tsx @@ -21,6 +21,7 @@ const TrackableFlatList = forwardRef( trackOnce, withFlashList = false, estimatedItemSize, + onLastViewedIndexChange, ...restProps }: TrackableFlatListProps, ref @@ -55,6 +56,15 @@ const TrackableFlatList = forwardRef( // Early return if no new viewed elements available if (!visibleItems.length) return + // Update with current visible index if callback provided + if (onLastViewedIndexChange) { + // Get the index of the last fully visible item + const currentVisibleIndex = visibleItems[visibleItems.length - 1]?.index ?? -1 + if (currentVisibleIndex >= 0) { + onLastViewedIndexChange(currentVisibleIndex) + } + } + // If "trackOnce" is false, we loop through all "visibleItems" and trigger tracking callback for all array elements if (!trackOnce) { visibleItems.forEach(({ item }) => trackItem(item)) diff --git a/core/tracking/events.ts b/core/tracking/events.ts index a8b3742e45f49fcc28ac450632502408cad5c838..93cbb18c40a80d9d55df0f74f007efa937312749 100644 --- a/core/tracking/events.ts +++ b/core/tracking/events.ts @@ -151,6 +151,18 @@ export namespace TrackingEvent { name: 'feedPostCreateStart', ...versionOne, }, + viewSummary: ( + totalElements: number, + feedPosts: number, + spacePosts: number, + goodNews: number, + insights: number, + volunteering: number + ): TrackingEvent => ({ + name: 'feedViewSummary', + properties: { totalElements, feedPosts, spacePosts, goodNews, insights, volunteering }, + ...versionOne, + }), } export const Spaces = {