diff --git a/packages/app/modules/feed/hooks/useFeed.ts b/packages/app/modules/feed/hooks/useFeed.ts index 2f84a08e6..499b10a75 100644 --- a/packages/app/modules/feed/hooks/useFeed.ts +++ b/packages/app/modules/feed/hooks/useFeed.ts @@ -3,6 +3,15 @@ import { useUserPacks, useSimilarPacks } from 'app/modules/pack'; import { useUserTrips } from 'app/modules/trip'; import { useSimilarItems } from 'app/modules/item'; +interface UseFeedResult { + data: any[] | null; + isLoading: boolean; + refetch?: () => void; + setPage?: (page: number) => void; + hasMore?: boolean; + fetchNextPage?: (isInitialFetch?: boolean) => Promise; +} + export const useFeed = ({ queryString = 'Most Recent', ownerId, @@ -15,10 +24,10 @@ export const useFeed = ({ feedType: string; selectedTypes: Object; id: string; -}> = {}) => { +}> = {}): UseFeedResult => { switch (feedType) { case 'public': - return usePublicFeed(queryString, selectedTypes); + return usePublicFeed(queryString, selectedTypes); // Use the typed return from usePublicFeed case 'userPacks': return useUserPacks(ownerId || undefined, queryString); case 'userTrips': @@ -28,6 +37,6 @@ export const useFeed = ({ case 'similarItems': return useSimilarItems(id); default: - return { data: null, error: null, isLoading: true }; + return { data: null, isLoading: true }; } -}; +}; \ No newline at end of file diff --git a/packages/app/modules/feed/hooks/usePublicFeed.ts b/packages/app/modules/feed/hooks/usePublicFeed.ts index 32fcbe4ce..1bb2ab8a7 100644 --- a/packages/app/modules/feed/hooks/usePublicFeed.ts +++ b/packages/app/modules/feed/hooks/usePublicFeed.ts @@ -1,4 +1,5 @@ import { queryTrpc } from 'app/trpc'; +import { useState, useEffect } from 'react'; type DataType = { type: string; @@ -11,66 +12,90 @@ type DataType = { pack_id: string | null; owner_id: string | null; is_public: boolean | null; - // ... rest }[]; -type OptionalDataType = { - [K in keyof DataType]?: DataType[K]; -}[]; +type OptionalDataType = DataType[]; -export const usePublicFeed = (queryString, selectedTypes) => { - let data: OptionalDataType = []; - let isLoading = true; - let refetch = () => {}; - try { - const queryOptions = { - refetchOnWindowFocus: false, - keepPreviousData: true, - staleTime: 1000 * 60, // 1 min - cacheTime: 1000 * 60 * 5, // 5 min - }; - const publicPacks = queryTrpc.getPublicPacks.useQuery( - { queryBy: queryString ?? 'Favorite' }, - { - ...queryOptions, - onSuccess: (data) => - console.log('Successfully fetched public packs!', data), - onError: (error) => - console.error('Error fetching public packs:', error), - }, - ); - - const publicTrips = queryTrpc.getPublicTripsRoute.useQuery( - { queryBy: queryString ?? 'Favorite' }, - { - ...queryOptions, - enabled: publicPacks?.status === 'success', - }, - ); - - isLoading = - publicPacks?.status !== 'success' && publicTrips?.status !== 'success'; - - if (selectedTypes.pack && publicPacks?.status === 'success') - data = [ - ...data, - ...publicPacks.data.map((item) => ({ ...item, type: 'pack' })), - ]; - - if (selectedTypes.trip && publicTrips?.status === 'success') - data = [ - ...data, - ...publicTrips.data.map((item) => ({ ...item, type: 'trip' })), - ]; - - refetch = () => { - publicPacks.refetch(); - publicTrips.refetch(); +export const usePublicFeed = ( + queryString: string, + selectedTypes, + initialPage = 1, + initialLimit = 4 +) => { + const [page, setPage] = useState(initialPage); + const [data, setData] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); + + // Fetch public packs using the useQuery hook + const { + data: publicPacksData, + isLoading: isPacksLoading, + refetch: refetchPacks, + } = queryTrpc.getPublicPacks.useQuery( + { queryBy: queryString ?? 'Favorite', page, limit: initialLimit }, + { keepPreviousData: true, enabled: selectedTypes.pack } + ); + + // Fetch public trips using the useQuery hook + const { + data: publicTripsData, + isLoading: isTripsLoading, + refetch: refetchTrips, + } = queryTrpc.getPublicTripsRoute.useQuery( + { queryBy: queryString ?? 'Favorite' }, + { enabled: selectedTypes.trip && publicPacksData?.length > 0 } + ); + + // Ensure that fetching logic behaves consistently + useEffect(() => { + const processFetchedData = () => { + if (!isPacksLoading && !isTripsLoading && (publicPacksData || publicTripsData)) { + let newData: OptionalDataType = []; + + // Fetch and append packs + if (selectedTypes.pack && publicPacksData) { + newData = [...newData, ...publicPacksData.map((item) => ({ ...item, type: 'pack' }))]; + } + + // Fetch and append trips + if (selectedTypes.trip && publicTripsData) { + newData = [...newData, ...publicTripsData.map((item) => ({ ...item, type: 'trip' }))]; + } + + // Update data in state + setData((prevData) => { + return page === initialPage ? newData : [...prevData, ...newData]; // Append for subsequent pages + }); + + // Set `hasMore` based on the data fetched + setHasMore(newData.length === initialLimit); + + // Reset loading states + setIsLoading(false); + setIsFetchingNextPage(false); + } }; - } catch (error) { - console.error(error); - return { data: null, error, isLoading, refetch }; - } - return { data, error: null, isLoading, refetch }; + processFetchedData(); + }, [publicPacksData, publicTripsData, page, selectedTypes]); + + // Fetch the next page of data + const fetchNextPage = async () => { + if (hasMore && !isLoading && !isFetchingNextPage) { + setIsFetchingNextPage(true); + setPage((prevPage) => prevPage + 1); // Increment the page before fetching new data + + // Fetch packs and trips for the next page + await refetchPacks(); + if (selectedTypes.trip) { + await refetchTrips(); + } + + setIsFetchingNextPage(false); // Reset fetching state after data fetch + } + }; + + return { data, isLoading, hasMore, fetchNextPage, refetch: refetchPacks }; }; diff --git a/packages/app/modules/feed/screens/FeedScreen.tsx b/packages/app/modules/feed/screens/FeedScreen.tsx index 70c766d2e..23c2408ef 100644 --- a/packages/app/modules/feed/screens/FeedScreen.tsx +++ b/packages/app/modules/feed/screens/FeedScreen.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useState } from 'react'; -import { FlatList, View, Platform } from 'react-native'; +import React, { useMemo, useState, useEffect } from 'react'; +import { FlatList, View, Platform, ActivityIndicator } from 'react-native'; import { FeedCard, FeedSearchFilter, SearchProvider } from '../components'; import { useRouter } from 'app/hooks/router'; import { fuseSearch } from 'app/utils/fuseSearch'; @@ -23,64 +23,71 @@ const ERROR_MESSAGES = { userTrips: 'No User Trips Available', }; -interface FeedItem { - id: string; - type: string; -} - -interface SelectedTypes { - pack: boolean; - trip: boolean; -} - interface FeedProps { feedType?: string; } -interface UseFeedResult { - data: any[] | null; - error: any | null; - isLoading: boolean; - refetch: () => void; -} - const Feed = ({ feedType = 'public' }: FeedProps) => { const router = useRouter(); - const [queryString, setQueryString] = useState('Favorite'); const [selectedTypes, setSelectedTypes] = useState({ pack: true, trip: false, }); - const [selectedTrips, setSelectedTrips] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [refreshing, setRefreshing] = useState(false); + const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); const user = useAuthUser(); const ownerId = user?.id; - const styles = useCustomStyles(loadStyles); - const { data, error, isLoading, refetch } = useFeed({ + + // Fetch feed data using the useFeed hook + const { data, isLoading, hasMore, fetchNextPage, refetch } = useFeed({ queryString, ownerId, feedType, selectedTypes, - }) as UseFeedResult; + }); + // Refresh data const onRefresh = () => { setRefreshing(true); - refetch(); + refetch && refetch(); // Ensure refetch is defined setRefreshing(false); }; - let arrayData = data; + // Fetch more data when reaching the end + const fetchMoreData = async () => { + // Ensure we are not already fetching and that there is more data to fetch + if (!isFetchingNextPage && hasMore && !isLoading) { + setIsFetchingNextPage(true); + await fetchNextPage(); // Call to fetch the next page + setIsFetchingNextPage(false); + } + }; + + // Web-specific scroll detection + useEffect(() => { + if (Platform.OS === 'web') { + const handleScroll = () => { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + if (scrollTop + windowHeight >= documentHeight - 50 && !isFetchingNextPage && hasMore) { + fetchMoreData(); + } + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); // Cleanup + } + }, [isFetchingNextPage, hasMore, isLoading]); + // Filter data based on search query const filteredData = useMemo(() => { - if (!arrayData) { - return []; - } - // Fuse search + if (!data) return []; const keys = ['name', 'items.name', 'items.category']; const options = { threshold: 0.4, @@ -89,20 +96,10 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { maxPatternLength: 32, minMatchCharLength: 1, }; - - const results = fuseSearch(arrayData, searchQuery, keys, options); - - // Convert fuse results back into the format we want - // if searchQuery is empty, use the original data + const results = fuseSearch(data, searchQuery, keys, options); return searchQuery ? results.map((result) => result.item) : data; }, [searchQuery, data]); - /** - * Renders the data for the feed based on the feed type and search query. - * - * @return {ReactNode} The rendered feed data. - */ - const handleTogglePack = () => { setSelectedTypes((prevState) => ({ ...prevState, @@ -121,21 +118,15 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { setQueryString(value); }; - const urlPath = URL_PATHS[feedType]; - const createUrlPath = URL_PATHS[feedType] + 'create'; - const errorText = ERROR_MESSAGES[feedType]; - const handleCreateClick = () => { - // handle create click logic + const createUrlPath = URL_PATHS[feedType] + 'create'; router.push(createUrlPath); }; return ( - + { ItemSeparatorComponent={() => ( )} - keyExtractor={(item) => item?.id + item?.type} + keyExtractor={(item, index) => `${item?.id}_${item?.type}_${index}`} // Ensure unique keys renderItem={({ item }) => ( { feedType={item.type} /> )} - ListFooterComponent={() => } + ListFooterComponent={() => + isFetchingNextPage || isLoading ? ( + + ) : ( + + ) + } ListEmptyComponent={() => ( {ERROR_MESSAGES[feedType]} )} + refreshControl={} + onEndReached={fetchMoreData} // Trigger next page fetch + onEndReachedThreshold={0.5} // Trigger when 50% from the bottom showsVerticalScrollIndicator={false} - refreshControl={ - - } maxToRenderPerBatch={2} /> @@ -179,39 +176,14 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { ); }; -const loadStyles = (theme) => { - const { currentTheme } = theme; - return { - mainContainer: { - flex: 1, - backgroundColor: currentTheme.colors.background, - fontSize: 18, - padding: 15, - ...(Platform.OS !== 'web' && { paddingBottom: 15, paddingTop: 0 }), - }, - // filterContainer: { - // backgroundColor: currentTheme.colors.card, - // padding: 15, - // fontSize: 18, - // width: '100%', - // borderRadius: 10, - // marginTop: 20, - // }, - // searchContainer: { - // flexDirection: 'row', - // alignItems: 'center', - // justifyContent: 'center', - // marginBottom: 10, - // padding: 10, - // borderRadius: 5, - // }, - // cardContainer: { - // flexDirection: 'row', - // flexWrap: 'wrap', - // justifyContent: 'space-around', - // alignItems: 'center', - // }, - }; -}; +const loadStyles = (theme) => ({ + mainContainer: { + flex: 1, + backgroundColor: theme.currentTheme.colors.background, + fontSize: 18, + padding: 15, + ...(Platform.OS !== 'web' && { paddingBottom: 15, paddingTop: 0 }), + }, +}); export default disableScreen(Feed, (props) => props.feedType === 'userTrips'); diff --git a/server/src/controllers/pack/getPublicPacks.ts b/server/src/controllers/pack/getPublicPacks.ts index e01e8b12b..b0f71d0c7 100644 --- a/server/src/controllers/pack/getPublicPacks.ts +++ b/server/src/controllers/pack/getPublicPacks.ts @@ -4,9 +4,11 @@ import { z } from 'zod'; import { type Context } from 'hono'; export const getPublicPacks = async (c: Context) => { + const query = c.req.query(); + const { page, limit } = query; try { - const { queryBy } = await c.req.query(); - const packs = await getPublicPacksService(queryBy); + const { queryBy } = query; + const packs = await getPublicPacksService(queryBy, Number(page), Number(limit)); return c.json( { packs, message: 'Public packs retrieved successfully' }, 200, @@ -21,10 +23,14 @@ export const getPublicPacks = async (c: Context) => { export function getPublicPacksRoute() { return protectedProcedure - .input(z.object({ queryBy: z.string() })) + .input(z.object({ + queryBy: z.string(), + page: z.number().optional(), + limit: z.number().optional(), + })) .query(async (opts) => { - const { queryBy } = opts.input; - const packs = await getPublicPacksService(queryBy); + const { queryBy, page, limit } = opts.input; + const packs = await getPublicPacksService(queryBy, page, limit); return packs; }); -} +} \ No newline at end of file diff --git a/server/src/drizzle/methods/pack.ts b/server/src/drizzle/methods/pack.ts index 1d8520c4f..8134bc0f0 100644 --- a/server/src/drizzle/methods/pack.ts +++ b/server/src/drizzle/methods/pack.ts @@ -151,6 +151,8 @@ export class Pack { sortOption, ownerId, is_public, + page , + limit , } = options; const filterConditions = []; @@ -169,11 +171,17 @@ export class Pack { includeRelated, completeItems: true, }); + + const offset = (page - 1) * limit; + const packs = await DbClient.instance.query.pack.findMany({ - ...(modifiedFilter && { where: modifiedFilter }), - orderBy: orderByFunction, - ...(includeRelated ? relations : {}), - }); + ...(modifiedFilter && { where: modifiedFilter }), + orderBy: orderByFunction, + ...(includeRelated ? relations : {}), + offset: offset, + limit: limit, + }); + return (await packs).map((pack: any) => ({ ...pack, scores: JSON.parse(pack.scores as string), diff --git a/server/src/services/pack/getPublicPacksService.ts b/server/src/services/pack/getPublicPacksService.ts index 0bfeba40a..4861445b2 100644 --- a/server/src/services/pack/getPublicPacksService.ts +++ b/server/src/services/pack/getPublicPacksService.ts @@ -5,9 +5,12 @@ import { SORT_OPTIONS, DEFAULT_SORT, sortFunction } from '../../utils/pack'; * Retrieves public packs based on the provided query parameter. * * @param {string} queryBy - Specifies how the public packs should be sorted. + * @param {number} page - The page number for pagination. + * @param {number} limit - The number of items per page. * @return {Promise} An array of public packs. */ -export async function getPublicPacksService(queryBy: string = 'createdAt') { + +export async function getPublicPacksService(queryBy: string = 'createdAt', page: number, limit: number) { try { const packClass = new Pack(); const sortOption = queryBy ? SORT_OPTIONS[queryBy] : DEFAULT_SORT; @@ -15,6 +18,8 @@ export async function getPublicPacksService(queryBy: string = 'createdAt') { sortOption, includeRelated: true, is_public: true, + page, + limit, }); // Apply sorting if necessary @@ -29,4 +34,4 @@ export async function getPublicPacksService(queryBy: string = 'createdAt') { } catch (error) { throw new Error('Packs cannot be found: ' + error.message); } -} +} \ No newline at end of file