fix(chat): Conversation auf inverted FlatList — Scroll-to-bottom bulletproof
Der setTimeout(80)+onImageLoad-Ansatz war ein Timing-Hack gegen ein strukturelles Problem (lazy Item-Measurement unter Fabric -> scrollToEnd landet zu kurz). Stattdessen jetzt inverted FlatList: Index 0 sitzt permanent am Bildschirmrand, neueste Nachricht immer sichtbar. - dm.tsx: inverted + reversedMessages, Gruppen-Logik gespiegelt, manuellen Auto-Scroll + keyboardHeight-State entfernt - ChatBubble.tsx: onImageLoad-Prop entfernt (obsolet) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
517ce8658f
commit
48a8bbc4af
@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -7,7 +7,6 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Keyboard,
|
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
@ -59,7 +58,6 @@ export default function DmScreen() {
|
|||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const styles = makeStyles(colors);
|
const styles = makeStyles(colors);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const flatRef = useRef<FlatList>(null);
|
|
||||||
const myUserId = useAuthStore((s) => s.user?.id);
|
const myUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
@ -67,7 +65,6 @@ export default function DmScreen() {
|
|||||||
|
|
||||||
const { userId } = useLocalSearchParams<{ userId: string }>();
|
const { userId } = useLocalSearchParams<{ userId: string }>();
|
||||||
|
|
||||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
||||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||||
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
|
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
|
||||||
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
|
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
|
||||||
@ -76,24 +73,12 @@ export default function DmScreen() {
|
|||||||
);
|
);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
||||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
||||||
const show = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height));
|
|
||||||
const hide = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
|
|
||||||
return () => {
|
|
||||||
show.remove();
|
|
||||||
hide.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse)
|
// Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setPartner(null);
|
setPartner(null);
|
||||||
partnerRef.current = null;
|
partnerRef.current = null;
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
isInitialLoad.current = true;
|
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// Lade DM-History — staleTime:0 erzwingt immer frischen Fetch (kein Cache-Hit-Bug)
|
// Lade DM-History — staleTime:0 erzwingt immer frischen Fetch (kein Cache-Hit-Bug)
|
||||||
@ -175,20 +160,7 @@ export default function DmScreen() {
|
|||||||
);
|
);
|
||||||
useDmRealtime(userId, onDmInsert, !!myUserId);
|
useDmRealtime(userId, onDmInsert, !!myUserId);
|
||||||
|
|
||||||
const isInitialLoad = useRef(true);
|
const reversedMessages = useMemo(() => [...messages].reverse(), [messages]);
|
||||||
|
|
||||||
const scrollToBottom = useCallback((animated: boolean) => {
|
|
||||||
setTimeout(() => flatRef.current?.scrollToEnd({ animated }), 80);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-Scroll bei neuen Messages
|
|
||||||
useEffect(() => {
|
|
||||||
if (messages.length > 0) {
|
|
||||||
const animated = !isInitialLoad.current;
|
|
||||||
isInitialLoad.current = false;
|
|
||||||
scrollToBottom(animated);
|
|
||||||
}
|
|
||||||
}, [messages.length, scrollToBottom]);
|
|
||||||
|
|
||||||
async function handleSend(payload: SendPayload) {
|
async function handleSend(payload: SendPayload) {
|
||||||
if (sending) return;
|
if (sending) return;
|
||||||
@ -308,28 +280,27 @@ export default function DmScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={flatRef}
|
inverted
|
||||||
data={messages}
|
data={reversedMessages}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<ChatBubble
|
<ChatBubble
|
||||||
msg={item}
|
msg={item}
|
||||||
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
|
isFirstInGroup={!sameAuthor(item, reversedMessages[index + 1])}
|
||||||
isLastInGroup={!sameAuthor(item, messages[index + 1])}
|
isLastInGroup={!sameAuthor(reversedMessages[index - 1], item)}
|
||||||
onReply={startReply}
|
onReply={startReply}
|
||||||
onLike={toggleLike}
|
onLike={toggleLike}
|
||||||
onOpenImage={() => {}}
|
onOpenImage={() => {}}
|
||||||
onImageLoad={index === messages.length - 1 ? () => scrollToBottom(false) : undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
keyExtractor={(m) => m.id}
|
keyExtractor={(m) => m.id}
|
||||||
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
|
contentContainerStyle={{ paddingBottom: 12, paddingTop: 8 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg }}>
|
<View style={{ paddingBottom: Math.max(12, insets.bottom), backgroundColor: colors.bg }}>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
sending={sending}
|
sending={sending}
|
||||||
|
|||||||
@ -47,7 +47,6 @@ type Props = {
|
|||||||
onReply: (msg: ChatMsg) => void;
|
onReply: (msg: ChatMsg) => void;
|
||||||
onLike: (msg: ChatMsg) => void;
|
onLike: (msg: ChatMsg) => void;
|
||||||
onOpenImage: (url: string) => void;
|
onOpenImage: (url: string) => void;
|
||||||
onImageLoad?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTime(ts: string) {
|
function formatTime(ts: string) {
|
||||||
@ -76,7 +75,6 @@ export function ChatBubble({
|
|||||||
onReply,
|
onReply,
|
||||||
onLike,
|
onLike,
|
||||||
onOpenImage,
|
onOpenImage,
|
||||||
onImageLoad,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
@ -203,7 +201,6 @@ export function ChatBubble({
|
|||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
cachePolicy="memory-disk"
|
cachePolicy="memory-disk"
|
||||||
transition={200}
|
transition={200}
|
||||||
onLoad={onImageLoad ? () => onImageLoad() : undefined}
|
|
||||||
/>
|
/>
|
||||||
{isImageOnly && (
|
{isImageOnly && (
|
||||||
<View style={styles.imageTimeOverlay}>
|
<View style={styles.imageTimeOverlay}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user