mono/packages/ui/src/hooks/useFeedData.ts
2026-04-09 00:11:32 +02:00

325 lines
13 KiB
TypeScript

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<FeedPost[]>(() => {
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<Error | null>(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<void>((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;
};