import { useState, useEffect, useCallback, useRef } from 'react'; import { FEED_API_ENDPOINT, FEED_PAGE_SIZE } from '@/constants'; import { useProfiles } from '@/contexts/ProfilesContext'; import { useFeedCache } from '@/contexts/FeedCacheContext'; import { augmentFeedPosts, FeedPost } from '@/modules/posts/client-posts'; import { searchContent, searchContentStream } from '@/modules/search/client-search'; import { getCurrentLang } from '@/i18n'; import { fetchWithDeduplication } from '@/lib/db'; import { fetchFeed } from '@/modules/feed/client-feed'; const { supabase } = await import('@/integrations/supabase/client'); export type FeedSortOption = 'latest' | 'top'; interface UseFeedDataProps { source?: 'home' | 'collection' | 'tag' | 'user' | 'widget' | 'search'; sourceId?: string; isOrgContext?: boolean; orgSlug?: string; enabled?: boolean; sortBy?: FeedSortOption; supabaseClient?: any; categoryIds?: string[]; categorySlugs?: string[]; contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places'; visibilityFilter?: 'invisible' | 'private'; } export const useFeedData = ({ source = 'home', sourceId, isOrgContext, orgSlug, enabled = true, sortBy = 'latest', supabaseClient, categoryIds, categorySlugs, contentType, visibilityFilter }: UseFeedDataProps) => { const { getCache, saveCache } = useFeedCache(); const categoryKey = categoryIds ? `-catIds${categoryIds.join(',')}` : ''; const slugKey = categorySlugs ? `-catSlugs${categorySlugs.join(',')}` : ''; const contentTypeKey = contentType ? `-ct${contentType}` : ''; const visibilityKey = visibilityFilter ? `-vis${visibilityFilter}` : ''; const cacheKey = `${source}-${sourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}-${sortBy}${categoryKey}${slugKey}${contentTypeKey}${visibilityKey}`; // Initialize from cache if available const [posts, setPosts] = useState(() => { const cached = getCache(cacheKey); return cached ? cached.posts : []; }); const [page, setPage] = useState(() => { const cached = getCache(cacheKey); return cached ? cached.page : 0; }); const [hasMore, setHasMore] = useState(() => { const cached = getCache(cacheKey); return cached !== null ? cached.hasMore : true; }); const [loading, setLoading] = useState(() => { const cached = getCache(cacheKey); return cached ? false : true; }); const [isFetchingMore, setIsFetchingMore] = useState(false); const [error, setError] = useState(null); const { fetchProfiles } = useProfiles(); // Keep track of active stream for search const streamRef = useRef<(() => void) | null>(null); // Track if we mounted with cache to avoid double fetch const mountedWithCache = useRef(!!getCache(cacheKey)); // Reset pagination when source changes (if not cached) useEffect(() => { const cached = getCache(cacheKey); if (cached) { setPosts(cached.posts); setPage(cached.page); setHasMore(cached.hasMore); setLoading(false); mountedWithCache.current = true; } else { setPosts([]); setPage(0); setHasMore(true); setLoading(true); mountedWithCache.current = false; } }, [source, sourceId, isOrgContext, orgSlug, cacheKey, getCache]); // Update Cache whenever state changes useEffect(() => { if (!loading && posts.length > 0) { const currentCache = getCache(cacheKey); saveCache(cacheKey, { posts, page, hasMore, scrollY: currentCache?.scrollY || 0 }); } }, [posts, page, hasMore, loading, cacheKey, saveCache, getCache]); const loadFeed = useCallback(async (currentPage: number, isLoadMore: boolean) => { if (!enabled) { setLoading(false); return; } if (isLoadMore) { setIsFetchingMore(true); } else { setLoading(true); } setError(null); let fetchedPosts: any[] = []; try { // 0. Search source — use streaming API if (source === 'search' && sourceId) { const client = supabaseClient || supabase; const { data: { session } } = await client.auth.getSession(); // Close any existing stream if (streamRef.current) { streamRef.current(); streamRef.current = null; } // First clear existing posts if it's a new search if (!isLoadMore) { setPosts([]); } setHasMore(false); // Search returns all results, no pagination await new Promise((resolve, reject) => { let firstChunkReceived = false; let currentStreamedPosts: any[] = []; streamRef.current = searchContentStream({ q: sourceId, limit: 50, type: contentType ? contentType as any : undefined, token: session?.access_token || undefined, visibilityFilter: visibilityFilter || undefined }, (hits) => { currentStreamedPosts = [...currentStreamedPosts, ...hits]; // Process and update state const augmentedPosts = augmentFeedPosts(currentStreamedPosts); const postsWithUpdatedCovers = updatePostCovers(augmentedPosts, sortBy); setPosts(sortFeedPosts(postsWithUpdatedCovers, sortBy)); // Fetch profiles for new hits const userIds = Array.from(new Set(hits.map((p: any) => p.user_id))); if (userIds.length > 0) { fetchProfiles(userIds as string[]); } // Resolve promise on first chunk so loading spinner hides if (!firstChunkReceived) { firstChunkReceived = true; resolve(); } }, (err) => { console.error('Stream failed:', err); if (!firstChunkReceived) reject(err); }, () => { setHasMore(false); if (!firstChunkReceived) resolve(); // Resolve even if empty }); }); return; // Early return as search updates state directly } else { const hasFilters = (categoryIds && categoryIds.length > 0) || (categorySlugs && categorySlugs.length > 0) || contentType || visibilityFilter; if (source === 'home' && !sourceId && !hasFilters && currentPage === 0 && window.__INITIAL_STATE__?.feed) { fetchedPosts = window.__INITIAL_STATE__.feed; window.__INITIAL_STATE__.feed = undefined; } else { // 2. API Fetch (Universal) const dedupKey = `feed-${cacheKey}-p${currentPage}`; fetchedPosts = await fetchWithDeduplication( dedupKey, () => fetchFeed({ source: source, sourceId: sourceId, page: currentPage, limit: FEED_PAGE_SIZE, sortBy: sortBy, categoryIds: categoryIds, categorySlugs: categorySlugs, contentType: contentType, visibilityFilter: visibilityFilter, lang: getCurrentLang() }) ); } if (fetchedPosts.length < FEED_PAGE_SIZE) { setHasMore(false); } else { setHasMore(true); } } // end else (non-search) // Augment posts (ensure cover, author profiles etc are set) const augmentedPosts = augmentFeedPosts(fetchedPosts); // Update cover based on sort mode const postsWithUpdatedCovers = updatePostCovers(augmentedPosts, sortBy); // Apply client-side sorting setPosts(prev => { const combined = isLoadMore ? [...prev, ...postsWithUpdatedCovers] : postsWithUpdatedCovers; // Re-sort the entire combined list to maintain correct order across pages return sortFeedPosts(combined, sortBy); }); // Fetch profiles via context for any users in the feed const userIds = Array.from(new Set(augmentedPosts.map((p: any) => p.user_id))); if (userIds.length > 0) { await fetchProfiles(userIds as string[]); } } catch (err) { setError(err instanceof Error ? err : new Error('Unknown error fetching feed')); } finally { setLoading(false); setIsFetchingMore(false); } }, [source, sourceId, isOrgContext, orgSlug, enabled, fetchProfiles, sortBy, supabaseClient, categoryIds, categorySlugs, contentType, visibilityFilter]); // Initial Load useEffect(() => { // If we initialized from cache, don't fetch page 0 again immediately. if (mountedWithCache.current) { mountedWithCache.current = false; return; } loadFeed(0, false); }, [loadFeed]); const loadMore = useCallback(() => { if (!hasMore || isFetchingMore || loading) return; const nextPage = page + 1; setPage(nextPage); loadFeed(nextPage, true); }, [hasMore, isFetchingMore, loading, page, loadFeed]); // Cleanup stream on unmount useEffect(() => { return () => { if (streamRef.current) { streamRef.current(); streamRef.current = null; } }; }, []); return { posts, loading, error, hasMore, loadMore, isFetchingMore }; }; // Helper function to update post covers based on sort mode const updatePostCovers = (posts: FeedPost[], sortBy: FeedSortOption): FeedPost[] => { return posts.map(post => { if (!post.pictures || post.pictures.length === 0) { return post; } const validPics = post.pictures.filter((p: any) => p.visible !== false); if (validPics.length === 0) { return post; } let newCover; if (sortBy === 'latest') { // For "Latest" mode: prefer is_selected, then first by position const selected = validPics.find((p: any) => p.is_selected); const sortedByPosition = [...validPics].sort((a, b) => (a.position || 0) - (b.position || 0)); newCover = selected || sortedByPosition[0]; } else { // For "Top" or default: Use first by position const sortedByPosition = [...validPics].sort((a, b) => (a.position || 0) - (b.position || 0)); newCover = sortedByPosition[0]; } return { ...post, cover: newCover }; }); }; // Helper function to sort feed posts const sortFeedPosts = (posts: FeedPost[], sortBy: FeedSortOption): FeedPost[] => { const sorted = [...posts]; if (sortBy === 'top') { // Sort by likes_count descending, then by created_at descending as tiebreaker sorted.sort((a, b) => { const likesA = a.likes_count || 0; const likesB = b.likes_count || 0; if (likesB !== likesA) { return likesB - likesA; } // Tiebreaker: most recent first return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); }); } else { // 'latest' - Sort by post creation date (most recent first) sorted.sort((a, b) => { return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); }); } return sorted; };