199 lines
7.4 KiB
TypeScript

import { useCallback, useState } from 'react';
import {
View,
Text,
ScrollView,
FlatList,
Pressable,
RefreshControl,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { AppHeader } from '../../components/AppHeader';
import { ComposeCard } from '../../components/ComposeCard';
import { PostCard } from '../../components/PostCard';
import { PostCardSkeleton } from '../../components/PostCardSkeleton';
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
import { useCommunityStore, type CommunityCategory, type CommunityPost } from '../../stores/community';
import { useCommunityRealtime } from '../../hooks/useCommunityRealtime';
import { colors } from '../../lib/theme';
type FilterChip = {
value: CommunityCategory;
label: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
};
export default function HomeScreen() {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Granular selectors: subscribing to the whole store (incl. optimisticLikes)
// would re-render the screen — and thus the FlatList — on every like.
const activeCategory = useCommunityStore((s) => s.activeCategory);
const setCategory = useCommunityStore((s) => s.setCategory);
const FILTERS: FilterChip[] = [
{ value: 'all', label: t('community.cat_all'), icon: 'grid-outline' },
{ value: 'games', label: t('community.cat_games'), icon: 'trophy-outline' },
{ value: 'domain_vote', label: t('community.cat_domain'), icon: 'shield-outline' },
{ value: 'lyra', label: t('community.cat_lyra'), icon: 'sparkles-outline' },
{ value: 'rebreak', label: t('community.cat_rebreak'), icon: 'megaphone-outline' },
];
const [filterOpen, setFilterOpen] = useState(false);
const [activeCommentsPostId, setActiveCommentsPostId] = useState<string | null>(null);
const { data: posts = [], isLoading, isRefetching, refetch } = useQuery<CommunityPost[]>({
queryKey: ['community-posts', activeCategory],
queryFn: () => apiFetch(`/api/community/posts?category=${activeCategory}&limit=30`),
staleTime: 60_000,
});
// Realtime: live updates für Posts (likes/comments/neue Posts/domain-vote-Status)
useCommunityRealtime(true);
const toggleFilter = (value: CommunityCategory) => {
const next = activeCategory === value ? 'all' : value;
setCategory(next);
setFilterOpen(false);
};
// Stable callbacks — passed to memoized PostCards. Inline arrows would
// bust React.memo on every parent render (which also happens on every
// realtime patch since the posts array gets a new reference).
const openComments = useCallback((postId: string) => {
setActiveCommentsPostId(postId);
}, []);
const closeComments = useCallback(() => setActiveCommentsPostId(null), []);
const keyExtractor = useCallback((item: CommunityPost) => item.id, []);
const renderItem = useCallback(
({ item }: { item: CommunityPost }) => (
<PostCard post={item} onCommentPress={openComments} />
),
[openComments],
);
return (
<View className="flex-1 bg-neutral-50">
<AppHeader />
<FlatList
data={isLoading ? [] : posts}
keyExtractor={keyExtractor}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 12, paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
// Performance tuning — measured on Galaxy A50 (older mid-range):
// VirtualizedList warned with dt=2.26s for ~7k content. These props
// shrink the per-batch render cost and reclaim off-screen views.
removeClippedSubviews
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={7}
updateCellsBatchingPeriod={50}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor={colors.brandOrange}
/>
}
ListHeaderComponent={
<View>
<ComposeCard onPosted={() => refetch()} />
{/* Filter toggle */}
<View className="mb-3">
<Pressable
onPress={() => setFilterOpen((o) => !o)}
className="flex-row items-center gap-1.5 self-start"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Ionicons
name={filterOpen ? 'close-outline' : 'options-outline'}
size={18}
color={activeCategory !== 'all' ? colors.brandOrange : '#737373'}
/>
{activeCategory !== 'all' && (
<Text className="text-xs text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{FILTERS.find((f) => f.value === activeCategory)?.label}
</Text>
)}
</Pressable>
{filterOpen && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mt-2"
contentContainerStyle={{ gap: 8, paddingBottom: 4 }}
>
{FILTERS.map((f) => {
const active = activeCategory === f.value;
return (
<Pressable
key={f.value}
onPress={() => toggleFilter(f.value)}
className={`flex-row items-center gap-1.5 h-8 px-3 rounded-full border ${
active
? 'bg-rebreak-500 border-rebreak-500'
: 'bg-white border-neutral-200'
}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<Ionicons
name={f.icon}
size={13}
color={active ? '#fff' : '#737373'}
/>
<Text
className={`text-xs ${
active ? 'text-white' : 'text-neutral-500'
}`}
style={{ fontFamily: 'Nunito_600SemiBold' }}
>
{f.label}
</Text>
</Pressable>
);
})}
</ScrollView>
)}
</View>
{/* Skeleton */}
{isLoading && (
<View>
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</View>
)}
</View>
}
ListEmptyComponent={
isLoading ? null : (
<View className="items-center py-16">
<Ionicons name="chatbubbles-outline" size={40} color="#d4d4d4" />
<Text className="text-sm text-neutral-400 mt-3 text-center" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('community.no_posts')}
</Text>
</View>
)
}
renderItem={renderItem}
/>
<PostCommentsSheet
postId={activeCommentsPostId}
visible={activeCommentsPostId !== null}
onClose={closeComments}
/>
</View>
);
}