chahinebrini 5c539f8937 feat(presence,sheets,chat): tester-build polish bundle
Online-Status (Phase 1+):
- UserAvatar mit 4 Size-Variants (sm/md/lg/xl) + integrierter Online-Dot
- OnlinePresenceProvider: Supabase-Channel + Following-Filter
- ChatHeaderStatus: "Online" neutral / "vor X min" offline
- useLastSeen + Heartbeat (60s interval + AppState-background ping)
- Privatsphäre-Toggle in profile/index

Sheets:
- FormSheet Android-keyboard-fix (Dimensions.get('screen'), kein
  useWindowDimensions-Kollaps), useKeyboardHandler statt manual
  Keyboard.addListener, state-reset on re-open
- PostCommentsSheet same Pattern + close-after-submit + drag bis under
  app-header
- ConnectMailSheet form-view refactor: scrollable, AES-Banner als
  footnote, field-order email→pw→label, fixed 0.85 über alle Steps

Chat:
- DmChatBackground iOS klecks fix (G transform statt nested Svg)
- ChatInput Lyra-1:1 (keyboardWillShow, surfaceElevated bubble,
  arrow-up send, attachment links)
- dm/room/chat headers + conversation-list nutzen UserAvatar
- Foreign-Profile "Nachricht"-Button öffnet richtige DM

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 08:06:47 +02:00

337 lines
12 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { View, Text, ScrollView, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { apiFetch } from '../../lib/api';
import { UserAvatar } from '../../components/UserAvatar';
import { PostCard } from '../../components/PostCard';
import { PostCardSkeleton } from '../../components/PostCardSkeleton';
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
import { type CommunityPost } from '../../stores/community';
type ForeignProfile = {
id: string;
nickname: string;
avatar: string | null;
tier: string;
totalPoints: number;
postsCount: number;
followersCount: number;
followingCount: number;
approvedDomainsCount: number;
isFollowing: boolean;
isSelf: boolean;
joinedAt: string;
recentPosts: unknown[];
};
function formatJoinedAt(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
} catch {
return '';
}
}
type StatProps = { value: string; label: string };
function ForeignStat({ value, label }: StatProps) {
const colors = useColors();
return (
<View
style={{
flex: 1,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 22, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{value}
</Text>
<Text style={{ marginTop: 2, fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
{label}
</Text>
</View>
);
}
export default function ForeignProfileScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const colors = useColors();
const { t } = useTranslation();
const { userId } = useLocalSearchParams<{ userId: string }>();
const [isFollowing, setIsFollowing] = useState(false);
const [localFollowersCount, setLocalFollowersCount] = useState<number | null>(null);
const [followPending, setFollowPending] = useState(false);
const [activeCommentsPostId, setActiveCommentsPostId] = useState<string | null>(null);
const openComments = useCallback((postId: string) => setActiveCommentsPostId(postId), []);
const closeComments = useCallback(() => setActiveCommentsPostId(null), []);
const { data: profile, isLoading, isError } = useQuery<ForeignProfile>({
queryKey: ['foreign-profile', userId],
queryFn: () => apiFetch<ForeignProfile>(`/api/social/profile/${userId}`),
enabled: !!userId,
});
const { data: userPosts = [], isLoading: postsLoading } = useQuery<CommunityPost[]>({
queryKey: ['community-posts', { userId }],
queryFn: () => apiFetch<CommunityPost[]>(`/api/community/posts?userId=${userId}&limit=20`),
enabled: !!userId,
});
useEffect(() => {
if (!profile) return;
if (profile.isSelf) {
router.replace('/profile');
return;
}
setIsFollowing(profile.isFollowing);
setLocalFollowersCount(profile.followersCount);
}, [profile]);
async function handleFollow() {
if (followPending || !profile) return;
const optimisticFollowing = !isFollowing;
const optimisticCount = (localFollowersCount ?? profile.followersCount) + (optimisticFollowing ? 1 : -1);
setIsFollowing(optimisticFollowing);
setLocalFollowersCount(optimisticCount);
setFollowPending(true);
try {
const res = await apiFetch<{ following: boolean; followersCount: number }>(
'/api/social/follow',
{ method: 'POST', body: { userId: profile.id } },
);
setIsFollowing(res.following);
setLocalFollowersCount(res.followersCount);
} catch {
setIsFollowing(!optimisticFollowing);
setLocalFollowersCount(localFollowersCount ?? profile.followersCount);
Alert.alert(t('common.error'), t('common.unknown_error'));
} finally {
setFollowPending(false);
}
}
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<View
style={{
paddingTop: insets.top,
backgroundColor: colors.groupedBg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<View style={{ height: 56, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12 }}>
<TouchableOpacity onPress={() => router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
<Ionicons name="chevron-back" size={22} color={colors.text} />
</TouchableOpacity>
</View>
</View>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color={colors.brandOrange} />
</View>
</View>
);
}
if (isError || !profile) {
return (
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<View
style={{
paddingTop: insets.top,
backgroundColor: colors.groupedBg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<View style={{ height: 56, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12 }}>
<TouchableOpacity onPress={() => router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
<Ionicons name="chevron-back" size={22} color={colors.text} />
</TouchableOpacity>
</View>
</View>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 24 }}>
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', textAlign: 'center', marginBottom: 16 }}>
{t('common.unknown_error')}
</Text>
<TouchableOpacity onPress={() => router.back()} activeOpacity={0.7}>
<Text style={{ fontSize: 14, color: colors.brandOrange, fontFamily: 'Nunito_600SemiBold' }}>
{t('common.back')}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
const displayFollowers = localFollowersCount ?? profile.followersCount;
return (
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<View
style={{
paddingTop: insets.top,
backgroundColor: colors.groupedBg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<View
style={{
height: 56,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
}}
>
<TouchableOpacity onPress={() => router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
<Ionicons name="chevron-back" size={22} color={colors.text} />
</TouchableOpacity>
<Text style={{ fontSize: 15, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
Profil
</Text>
<View style={{ width: 38 }} />
</View>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
showsVerticalScrollIndicator={false}
>
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}>
<View>
<UserAvatar
userId={userId ?? null}
avatar={profile.avatar}
nickname={profile.nickname}
size="xl"
/>
</View>
<Text style={{ marginTop: 16, fontSize: 22, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{profile.nickname}
</Text>
<Text style={{ marginTop: 6, fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
Mitglied seit {formatJoinedAt(profile.joinedAt)}
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, width: '100%' }}>
<TouchableOpacity
onPress={handleFollow}
disabled={followPending}
activeOpacity={0.7}
style={{ flex: 1 }}
>
<View style={{
paddingVertical: 11,
borderRadius: 12,
backgroundColor: isFollowing ? colors.surfaceElevated : colors.brandOrange,
borderWidth: 1,
borderColor: isFollowing ? colors.border : colors.brandOrange,
alignItems: 'center',
opacity: followPending ? 0.6 : 1,
}}>
<Text style={{ fontSize: 13, color: isFollowing ? colors.text : '#ffffff', fontFamily: 'Nunito_600SemiBold' }}>
{isFollowing ? 'Folge ich' : 'Folgen'}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => router.push({ pathname: '/dm', params: { userId: profile.id } })}
activeOpacity={0.7}
style={{ flex: 1 }}
>
<View style={{
paddingVertical: 11,
borderRadius: 12,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
}}>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
Nachricht
</Text>
</View>
</TouchableOpacity>
</View>
</View>
<View style={{ height: 1, backgroundColor: 'rgba(0,0,0,0.06)', marginHorizontal: 16 }} />
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, paddingHorizontal: 16 }}>
<ForeignStat value={String(profile.postsCount)} label="Posts" />
<ForeignStat value={String(displayFollowers)} label="Follower" />
<ForeignStat value={String(profile.approvedDomainsCount)} label="Approved" />
</View>
<View style={{ marginTop: 24, paddingHorizontal: 16 }}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
marginBottom: 8,
}}
>
{t('community.recent_posts')}
</Text>
{postsLoading ? (
<View>
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</View>
) : userPosts.length === 0 ? (
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', textAlign: 'center' }}>
{t('community.no_posts')}
</Text>
</View>
) : (
userPosts.map((post) => (
<PostCard key={post.id} post={post} onCommentPress={openComments} />
))
)}
</View>
</ScrollView>
<PostCommentsSheet
postId={activeCommentsPostId}
visible={activeCommentsPostId !== null}
onClose={closeComments}
/>
</View>
);
}