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>
337 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|