chahinebrini d55cbc11b2 fix(native): mail-sheet modal-conflict + google-oauth picker + feed-bg contrast
- mail/MailAccountSettingsSheet: handleSaveTitle + handleSavePassword now
  dismiss sheet FIRST, then trigger parent SuccessAlert via setTimeout(350ms).
  Fixes iOS "already presenting" crash + page-freeze when editing mailbox name.
  Also fixes double-click-needed UX bug.
- stores/auth: signOut adds WebBrowser.coolDownAsync() to clear OAuth cookies.
  signInWithOAuth for Google adds prompt=select_account — forces account-picker
  on every sign-in attempt instead of auto-reusing previous account.
- app/(app)/index: feed page uses colors.groupedBg instead of colors.bg —
  matches iOS Mail/Messages list-style, post-cards stand out clearer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:16:34 +02:00

208 lines
7.7 KiB
TypeScript

import { useCallback, useState } from 'react';
import {
View,
Text,
ScrollView,
FlatList,
TouchableOpacity,
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 { useColors } 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();
const colors = useColors();
// 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 style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<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">
<TouchableOpacity
onPress={() => setFilterOpen((o) => !o)}
activeOpacity={0.6}
className="flex-row items-center gap-1.5 self-start"
>
<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>
)}
</TouchableOpacity>
{filterOpen && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mt-2"
contentContainerStyle={{ gap: 8, paddingBottom: 4 }}
>
{FILTERS.map((f) => {
const active = activeCategory === f.value;
return (
<TouchableOpacity
key={f.value}
onPress={() => toggleFilter(f.value)}
activeOpacity={0.7}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: 32,
paddingHorizontal: 12,
borderRadius: 999,
borderWidth: 1,
backgroundColor: active ? colors.brandOrange : colors.surface,
borderColor: active ? colors.brandOrange : colors.border,
}}
>
<Ionicons
name={f.icon}
size={13}
color={active ? '#fff' : colors.textMuted}
/>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: active ? '#fff' : colors.textMuted,
}}
>
{f.label}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
)}
</View>
{/* Skeleton */}
{isLoading && (
<View>
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</View>
)}
</View>
}
ListEmptyComponent={
isLoading ? null : (
<View style={{ alignItems: 'center', paddingVertical: 64 }}>
<Ionicons name="chatbubbles-outline" size={40} color={colors.border} />
<Text style={{ fontSize: 14, color: colors.textMuted, marginTop: 12, textAlign: 'center', fontFamily: 'Nunito_400Regular' }}>
{t('community.no_posts')}
</Text>
</View>
)
}
renderItem={renderItem}
/>
<PostCommentsSheet
postId={activeCommentsPostId}
visible={activeCommentsPostId !== null}
onClose={closeComments}
/>
</View>
);
}