(app)/index.tsx: - FlatList keyboardShouldPersistTaps="handled" — Bild-Icon im ComposeCard reagiert ab erstem Tap auch wenn Tastatur offen. Vorher dismisste der Tap nur die Tastatur (RN-Default "never"). ComposeCard.tsx Teilen-Button: - height 44→52, px-5→px-6, paper-plane-outline-Icon size 18 + text-base Nunito_700Bold. Standard-iOS-Filled-Primary-Button-Style. AppHeader.tsx Bell + Avatar: - hitSlop 4pt allseitig auf beiden Pressables — effective tap-area 36→44pt ohne Layout-Verschiebung - Bell-Icon size 18→22 (konsistent mit Avatar-36pt-Kreis) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
200 lines
7.4 KiB
TypeScript
200 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}
|
|
keyboardShouldPersistTaps="handled"
|
|
// 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>
|
|
);
|
|
}
|