feat(dm): info sheet + avatar→profile + scroll fix + image cache

- Header: Avatar-Tap navigiert direkt zum Profil des Chatpartners
- Header: Info-Icon (ℹ) rechts öffnet neues Info-Sheet
- Header: kein separater BG mehr — blendet in native Background ein
- Info-Sheet (pageSheet Modal):
    - Partner-Karte mit Avatar + "Profil anzeigen"-Link
    - Geteilte Medien als 3-Spalten-Grid (neueste zuerst)
    - Tap auf Bild → Lightbox-Modal (Vollbild mit Close-Button)
- Scroll-to-bottom: 3-stufiges Timing (rAF + 100ms + 300ms) für
  zuverlässiges Bottom-Scroll auch wenn Bilder nachgeladen werden
- expo-image cachePolicy="memory-disk" überall: ChatBubble-Image +
  alle Images im Info-Sheet + Lightbox
- i18n: dm.view_profile / dm.shared_media / dm.no_shared_media (DE/EN/FR/AR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-01 10:00:03 +02:00
parent 24044c3a0c
commit f59d1800c7
6 changed files with 223 additions and 8 deletions

View File

@ -10,6 +10,9 @@ import {
ActivityIndicator, ActivityIndicator,
StyleSheet, StyleSheet,
Keyboard, Keyboard,
Modal,
ScrollView,
Dimensions,
type FlatList as FlatListType, type FlatList as FlatListType,
} from 'react-native'; } from 'react-native';
import { KeyboardStickyView } from 'react-native-keyboard-controller'; import { KeyboardStickyView } from 'react-native-keyboard-controller';
@ -88,6 +91,8 @@ export default function DmScreen() {
const [keyboardVisible, setKeyboardVisible] = useState(false); const [keyboardVisible, setKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0); const [keyboardHeight, setKeyboardHeight] = useState(0);
const [inputBarHeight, setInputBarHeight] = useState(60); const [inputBarHeight, setInputBarHeight] = useState(60);
const [infoSheetOpen, setInfoSheetOpen] = useState(false);
const [lightboxUri, setLightboxUri] = useState<string | null>(null);
// Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse) // Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse)
useEffect(() => { useEffect(() => {
@ -162,8 +167,11 @@ export default function DmScreen() {
deleted: m.deleted ?? false, deleted: m.deleted ?? false,
})); }));
setMessages(msgs); setMessages(msgs);
// Dreistufiges Scroll-to-bottom: rAF + 100ms + 300ms deckt
// Fälle ab wo Bilder nachgeladen werden und Content-Höhe wächst.
requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false })); requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false }));
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 100); setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 100);
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 300);
return data; return data;
} catch (err: any) { } catch (err: any) {
console.error('[dm] history fetch failed:', err?.message ?? err); console.error('[dm] history fetch failed:', err?.message ?? err);
@ -460,13 +468,17 @@ export default function DmScreen() {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<View <View style={[styles.header, { backgroundColor: colors.bg }]}>
style={[styles.header, { backgroundColor: colors.surface }]}
>
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} hitSlop={8} activeOpacity={0.7}> <TouchableOpacity style={styles.backBtn} onPress={() => router.back()} hitSlop={8} activeOpacity={0.7}>
<Ionicons name="chevron-back" size={22} color={colors.text} /> <Ionicons name="chevron-back" size={22} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerCenter}>
{/* Avatar + Name — tap → Profil */}
<TouchableOpacity
style={styles.headerCenter}
activeOpacity={0.7}
onPress={() => userId && router.push(`/profile/${userId}` as any)}
>
<View style={{ marginRight: 8 }}> <View style={{ marginRight: 8 }}>
<UserAvatar <UserAvatar
userId={userId ?? null} userId={userId ?? null}
@ -481,7 +493,17 @@ export default function DmScreen() {
</Text> </Text>
{userId && <ChatHeaderStatus userId={userId} />} {userId && <ChatHeaderStatus userId={userId} />}
</View> </View>
</View> </TouchableOpacity>
{/* Info-Button */}
<TouchableOpacity
style={styles.infoBtn}
hitSlop={8}
activeOpacity={0.7}
onPress={() => setInfoSheetOpen(true)}
>
<Ionicons name="information-circle-outline" size={24} color={colors.text} />
</TouchableOpacity>
</View> </View>
<View style={{ flex: 1, backgroundColor: chatBg }}> <View style={{ flex: 1, backgroundColor: chatBg }}>
@ -610,10 +632,180 @@ export default function DmScreen() {
</View> </View>
</View> </View>
</KeyboardStickyView> </KeyboardStickyView>
{/* ── Info-Sheet ─────────────────────────────────────────────── */}
<DmInfoSheet
visible={infoSheetOpen}
onClose={() => setInfoSheetOpen(false)}
partner={partner}
messages={messages}
onImagePress={(uri) => setLightboxUri(uri)}
onViewProfile={() => {
setInfoSheetOpen(false);
setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250);
}}
colors={colors}
t={t}
/>
{/* ── Lightbox ───────────────────────────────────────────────── */}
<Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={() => setLightboxUri(null)}>
<TouchableOpacity
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }}
activeOpacity={1}
onPress={() => setLightboxUri(null)}
>
{lightboxUri && (
<Image
source={{ uri: lightboxUri }}
style={{ width: Dimensions.get('window').width, height: Dimensions.get('window').width }}
contentFit="contain"
cachePolicy="memory-disk"
/>
)}
<TouchableOpacity
style={{ position: 'absolute', top: 54, right: 20, padding: 8 }}
onPress={() => setLightboxUri(null)}
activeOpacity={0.7}
>
<Ionicons name="close-circle" size={32} color="#fff" />
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</SafeAreaView> </SafeAreaView>
); );
} }
// ─── DmInfoSheet ─────────────────────────────────────────────────────────────
const MEDIA_COL = 3;
const MEDIA_GAP = 2;
const MEDIA_SIZE = (Dimensions.get('window').width - MEDIA_GAP * (MEDIA_COL + 1)) / MEDIA_COL;
function DmInfoSheet({
visible,
onClose,
partner,
messages,
onImagePress,
onViewProfile,
colors,
t,
}: {
visible: boolean;
onClose: () => void;
partner: { id: string; nickname: string; avatar?: string | null } | null;
messages: ChatMsg[];
onImagePress: (uri: string) => void;
onViewProfile: () => void;
colors: ReturnType<typeof useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const sharedMedia = messages.filter(
(m) => m.attachmentType === 'image' && m.attachmentUrl,
);
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
{/* Header */}
<View style={{
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
paddingTop: 16, paddingBottom: 12,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border,
}}>
<Text style={{ flex: 1, fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('chat.info')}
</Text>
<TouchableOpacity onPress={onClose} hitSlop={8} activeOpacity={0.7}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
{/* Partner-Karte */}
<TouchableOpacity
activeOpacity={0.7}
onPress={onViewProfile}
style={{
flexDirection: 'row', alignItems: 'center',
padding: 16, gap: 14,
}}
>
{partner?.avatar ? (
<Image
source={{ uri: partner.avatar }}
style={{ width: 56, height: 56, borderRadius: 28 }}
contentFit="cover"
cachePolicy="memory-disk"
/>
) : (
<View style={{
width: 56, height: 56, borderRadius: 28,
backgroundColor: colors.brandOrange + '30',
alignItems: 'center', justifyContent: 'center',
}}>
<Text style={{ fontSize: 20, fontFamily: 'Nunito_700Bold', color: colors.brandOrange }}>
{partner?.nickname?.[0]?.toUpperCase() ?? '?'}
</Text>
</View>
)}
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{partner?.nickname ?? '…'}
</Text>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2 }}>
{t('dm.view_profile', { defaultValue: 'Profil anzeigen' })}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textMuted} />
</TouchableOpacity>
<View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.border, marginHorizontal: 16 }} />
{/* Geteilte Medien */}
<View style={{ paddingHorizontal: 16, paddingTop: 20, paddingBottom: 12 }}>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('dm.shared_media', { defaultValue: 'Geteilte Medien' })}
{sharedMedia.length > 0 && (
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{' '}{sharedMedia.length}
</Text>
)}
</Text>
</View>
{sharedMedia.length === 0 ? (
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
<Ionicons name="images-outline" size={40} color={colors.textMuted} />
<Text style={{ marginTop: 10, fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('dm.no_shared_media', { defaultValue: 'Keine geteilten Medien' })}
</Text>
</View>
) : (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: MEDIA_GAP, paddingHorizontal: MEDIA_GAP }}>
{[...sharedMedia].reverse().map((m) => (
<TouchableOpacity
key={m.id}
activeOpacity={0.8}
onPress={() => onImagePress(m.attachmentUrl!)}
>
<Image
source={{ uri: m.attachmentUrl! }}
style={{ width: MEDIA_SIZE, height: MEDIA_SIZE }}
contentFit="cover"
cachePolicy="memory-disk"
/>
</TouchableOpacity>
))}
</View>
)}
</ScrollView>
</View>
</Modal>
);
}
function makeStyles(colors: ReturnType<typeof useColors>) { function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({ return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg }, container: { flex: 1, backgroundColor: colors.bg },
@ -622,8 +814,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
}, },
backBtn: { backBtn: {
padding: 8, padding: 8,
@ -636,6 +826,11 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
alignItems: 'center', alignItems: 'center',
marginLeft: 8, marginLeft: 8,
}, },
infoBtn: {
padding: 8,
alignItems: 'center',
justifyContent: 'center',
},
headerName: { headerName: {
fontSize: 15, fontSize: 15,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',

View File

@ -146,7 +146,7 @@ export function ChatBubble({
]} ]}
> >
{msg.attachmentUrl && msg.attachmentType === 'image' ? ( {msg.attachmentUrl && msg.attachmentType === 'image' ? (
<Image source={{ uri: msg.attachmentUrl }} style={styles.image} contentFit="cover" /> <Image source={{ uri: msg.attachmentUrl }} style={styles.image} contentFit="cover" cachePolicy="memory-disk" />
) : msg.content !== '' ? ( ) : msg.content !== '' ? (
<Text style={[styles.content, { color: bubbleText }]}>{msg.content}</Text> <Text style={[styles.content, { color: bubbleText }]}>{msg.content}</Text>
) : null} ) : null}

View File

@ -1013,6 +1013,11 @@
"photo_access_title": "الوصول إلى الصور", "photo_access_title": "الوصول إلى الصور",
"photo_access_body": "يرجى السماح بالوصول إلى الصور في الإعدادات." "photo_access_body": "يرجى السماح بالوصول إلى الصور في الإعدادات."
}, },
"dm": {
"view_profile": "عرض الملف الشخصي",
"shared_media": "الوسائط المشتركة",
"no_shared_media": "لا توجد وسائط مشتركة"
},
"community": { "community": {
"compose_placeholder": "ما الذي يشغلك الآن؟", "compose_placeholder": "ما الذي يشغلك الآن؟",
"compose_default_user": "أنت", "compose_default_user": "أنت",

View File

@ -1078,6 +1078,11 @@
"photo_access_title": "Foto-Zugriff", "photo_access_title": "Foto-Zugriff",
"photo_access_body": "Bitte erlaube den Foto-Zugriff in den Einstellungen." "photo_access_body": "Bitte erlaube den Foto-Zugriff in den Einstellungen."
}, },
"dm": {
"view_profile": "Profil anzeigen",
"shared_media": "Geteilte Medien",
"no_shared_media": "Keine geteilten Medien"
},
"community": { "community": {
"compose_placeholder": "Was bewegt dich gerade?", "compose_placeholder": "Was bewegt dich gerade?",
"compose_default_user": "Du", "compose_default_user": "Du",

View File

@ -1078,6 +1078,11 @@
"photo_access_title": "Photo access", "photo_access_title": "Photo access",
"photo_access_body": "Please allow photo access in Settings." "photo_access_body": "Please allow photo access in Settings."
}, },
"dm": {
"view_profile": "View profile",
"shared_media": "Shared Media",
"no_shared_media": "No shared media yet"
},
"community": { "community": {
"compose_placeholder": "What's on your mind?", "compose_placeholder": "What's on your mind?",
"compose_default_user": "You", "compose_default_user": "You",

View File

@ -1000,6 +1000,11 @@
"photo_access_title": "Accès aux photos", "photo_access_title": "Accès aux photos",
"photo_access_body": "Veuillez autoriser l'accès aux photos dans les paramètres." "photo_access_body": "Veuillez autoriser l'accès aux photos dans les paramètres."
}, },
"dm": {
"view_profile": "Voir le profil",
"shared_media": "Médias partagés",
"no_shared_media": "Aucun média partagé"
},
"community": { "community": {
"compose_placeholder": "Qu'est-ce qui vous préoccupe en ce moment ?", "compose_placeholder": "Qu'est-ce qui vous préoccupe en ce moment ?",
"compose_default_user": "Vous", "compose_default_user": "Vous",