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:
parent
24044c3a0c
commit
f59d1800c7
@ -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',
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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": "أنت",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user