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,
|
||||
StyleSheet,
|
||||
Keyboard,
|
||||
Modal,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
type FlatList as FlatListType,
|
||||
} from 'react-native';
|
||||
import { KeyboardStickyView } from 'react-native-keyboard-controller';
|
||||
@ -88,6 +91,8 @@ export default function DmScreen() {
|
||||
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
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)
|
||||
useEffect(() => {
|
||||
@ -162,8 +167,11 @@ export default function DmScreen() {
|
||||
deleted: m.deleted ?? false,
|
||||
}));
|
||||
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 }));
|
||||
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 100);
|
||||
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 300);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
console.error('[dm] history fetch failed:', err?.message ?? err);
|
||||
@ -460,13 +468,17 @@ export default function DmScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View
|
||||
style={[styles.header, { backgroundColor: colors.surface }]}
|
||||
>
|
||||
<View style={[styles.header, { backgroundColor: colors.bg }]}>
|
||||
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} hitSlop={8} activeOpacity={0.7}>
|
||||
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||
</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 }}>
|
||||
<UserAvatar
|
||||
userId={userId ?? null}
|
||||
@ -481,7 +493,17 @@ export default function DmScreen() {
|
||||
</Text>
|
||||
{userId && <ChatHeaderStatus userId={userId} />}
|
||||
</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 style={{ flex: 1, backgroundColor: chatBg }}>
|
||||
@ -610,10 +632,180 @@ export default function DmScreen() {
|
||||
</View>
|
||||
</View>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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>) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
@ -622,8 +814,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
backBtn: {
|
||||
padding: 8,
|
||||
@ -636,6 +826,11 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
infoBtn: {
|
||||
padding: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerName: {
|
||||
fontSize: 15,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
|
||||
@ -146,7 +146,7 @@ export function ChatBubble({
|
||||
]}
|
||||
>
|
||||
{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 !== '' ? (
|
||||
<Text style={[styles.content, { color: bubbleText }]}>{msg.content}</Text>
|
||||
) : null}
|
||||
|
||||
@ -1013,6 +1013,11 @@
|
||||
"photo_access_title": "الوصول إلى الصور",
|
||||
"photo_access_body": "يرجى السماح بالوصول إلى الصور في الإعدادات."
|
||||
},
|
||||
"dm": {
|
||||
"view_profile": "عرض الملف الشخصي",
|
||||
"shared_media": "الوسائط المشتركة",
|
||||
"no_shared_media": "لا توجد وسائط مشتركة"
|
||||
},
|
||||
"community": {
|
||||
"compose_placeholder": "ما الذي يشغلك الآن؟",
|
||||
"compose_default_user": "أنت",
|
||||
|
||||
@ -1078,6 +1078,11 @@
|
||||
"photo_access_title": "Foto-Zugriff",
|
||||
"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": {
|
||||
"compose_placeholder": "Was bewegt dich gerade?",
|
||||
"compose_default_user": "Du",
|
||||
|
||||
@ -1078,6 +1078,11 @@
|
||||
"photo_access_title": "Photo access",
|
||||
"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": {
|
||||
"compose_placeholder": "What's on your mind?",
|
||||
"compose_default_user": "You",
|
||||
|
||||
@ -1000,6 +1000,11 @@
|
||||
"photo_access_title": "Accès aux photos",
|
||||
"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": {
|
||||
"compose_placeholder": "Qu'est-ce qui vous préoccupe en ce moment ?",
|
||||
"compose_default_user": "Vous",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user