fix(dm): always scroll to bottom on new messages and content-size changes

The smart isNearBottomRef gating was too restrictive — own sent messages,
image-loads, and incoming partner messages were sometimes not scrolled to.
Adopt the room-chat pattern: always scroll on messages.length change and
onContentSizeChange. Drop isNearBottomRef + firstContentSizeChangeRef +
onScroll handler.
This commit is contained in:
chahinebrini 2026-05-31 01:37:44 +02:00
parent 55e3cdfb26
commit 49e558902b

View File

@ -75,7 +75,6 @@ export default function DmScreen() {
const { userId } = useLocalSearchParams<{ userId: string }>(); const { userId } = useLocalSearchParams<{ userId: string }>();
const flatListRef = useRef<FlatListType<ChatMsg>>(null); const flatListRef = useRef<FlatListType<ChatMsg>>(null);
const isNearBottomRef = useRef(true);
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);
@ -163,6 +162,7 @@ export default function DmScreen() {
})); }));
setMessages(msgs); setMessages(msgs);
requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false })); requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false }));
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 100);
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);
@ -174,12 +174,10 @@ export default function DmScreen() {
gcTime: 0, gcTime: 0,
}); });
// Neue Nachricht (incoming Realtime oder outgoing send) — nur scrollen wenn nahe unten // Neue Nachricht (incoming Realtime oder outgoing send) → immer scrollen
useEffect(() => { useEffect(() => {
if (messages.length === 0) return; if (messages.length === 0) return;
if (isNearBottomRef.current) {
requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: true })); requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: true }));
}
}, [messages.length]); }, [messages.length]);
// Realtime: neue DMs vom Partner // Realtime: neue DMs vom Partner
@ -234,13 +232,12 @@ export default function DmScreen() {
} }
} }
async function uploadAttachment(): Promise<{ url: string; type: string; name: string } | null> { async function uploadAttachment(file: { uri: string; name: string }): Promise<{ url: string; type: string; name: string } | null> {
if (!attachment) return null;
try { try {
setUploading(true); setUploading(true);
const ext = attachment.name.split('.').pop() || 'jpg'; const ext = file.name.split('.').pop() || 'jpg';
const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
const base64 = await FileSystem.readAsStringAsync(attachment.uri, { const base64 = await FileSystem.readAsStringAsync(file.uri, {
encoding: FileSystem.EncodingType.Base64, encoding: FileSystem.EncodingType.Base64,
}); });
const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary'); const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary');
@ -253,7 +250,7 @@ export default function DmScreen() {
}); });
if (error) throw error; if (error) throw error;
const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path); const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
return { url: data.publicUrl, type: 'image', name: attachment.name }; return { url: data.publicUrl, type: 'image', name: file.name };
} catch (err: any) { } catch (err: any) {
Alert.alert(t('chat.upload_failed'), err?.message ?? ''); Alert.alert(t('chat.upload_failed'), err?.message ?? '');
return null; return null;
@ -266,58 +263,91 @@ export default function DmScreen() {
const content = inputText.trim(); const content = inputText.trim();
if (!content && !attachment) return; if (!content && !attachment) return;
if (sending || uploading) return; if (sending || uploading) return;
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const currentReplyTo = replyTo;
const currentAttachment = attachment;
const optimisticMsg: ChatMsg = {
id: tempId,
userId: myUserId ?? '',
nickname: 'Du',
avatar: null,
content,
replyTo: currentReplyTo
? {
id: currentReplyTo.id,
userId: myUserId ?? '',
nickname: currentReplyTo.nickname,
content: currentReplyTo.content,
attachmentType: null,
}
: null,
attachmentUrl: currentAttachment?.uri ?? null,
attachmentType: currentAttachment ? 'image' : null,
attachmentName: currentAttachment?.name ?? null,
likesCount: 0,
likedByMe: false,
createdAt: new Date().toISOString(),
isOwn: true,
readAt: null,
status: 'pending',
};
setMessages((prev) => [...prev, optimisticMsg]);
setInputText('');
setAttachment(null);
setReplyTo(null);
setSending(true); setSending(true);
try { try {
let attachmentMeta: { url: string; type: string; name: string } | null = null; let attachmentMeta: { url: string; type: string; name: string } | null = null;
if (attachment) { if (currentAttachment) {
attachmentMeta = await uploadAttachment(); attachmentMeta = await uploadAttachment(currentAttachment);
if (!attachmentMeta) { setSending(false); return; } if (!attachmentMeta) {
setMessages((prev) =>
prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)),
);
setSending(false);
return;
} }
}
const newMsg = await apiFetch<any>('/api/chat/dm', { const newMsg = await apiFetch<any>('/api/chat/dm', {
method: 'POST', method: 'POST',
body: { body: {
receiverId: userId, receiverId: userId,
content, content,
replyToId: replyTo?.id, replyToId: currentReplyTo?.id,
attachmentUrl: attachmentMeta?.url, attachmentUrl: attachmentMeta?.url,
attachmentType: attachmentMeta?.type, attachmentType: attachmentMeta?.type,
attachmentName: attachmentMeta?.name, attachmentName: attachmentMeta?.name,
}, },
}); });
setMessages((prev) => [
...prev, setMessages((prev) =>
{ prev.map((m) =>
id: newMsg.id, m.id === tempId
userId: myUserId ?? '',
nickname: 'Du',
avatar: null,
content: newMsg.content,
replyTo: newMsg.replyTo
? { ? {
id: newMsg.replyTo.id, ...m,
userId: newMsg.replyTo.senderId, id: newMsg.id,
nickname: content: newMsg.content,
newMsg.replyTo.senderId === myUserId ? 'Du' : partner?.nickname ?? '?', attachmentUrl: newMsg.attachmentUrl ?? null,
content: newMsg.replyTo.content?.slice(0, 100) ?? '', attachmentType: newMsg.attachmentType ?? null,
attachmentType: newMsg.replyTo.attachmentType ?? null, attachmentName: newMsg.attachmentName ?? null,
}
: null,
attachmentUrl: newMsg.attachmentUrl,
attachmentType: newMsg.attachmentType,
attachmentName: newMsg.attachmentName,
likesCount: newMsg.likesCount ?? 0,
likedByMe: false,
createdAt: newMsg.createdAt, createdAt: newMsg.createdAt,
isOwn: true, status: 'sent' as const,
readAt: null, }
}, : m,
]); ),
setInputText(''); );
setAttachment(null);
setReplyTo(null);
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
} catch (err) { } catch (err) {
console.error('DM send failed:', err); console.error('DM send failed:', err);
setMessages((prev) =>
prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)),
);
} finally { } finally {
setSending(false); setSending(false);
} }
@ -491,17 +521,7 @@ export default function DmScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
onScroll={(e) => { onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })}
const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent;
const distFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height;
isNearBottomRef.current = distFromBottom < 80;
}}
scrollEventThrottle={100}
onContentSizeChange={() => {
if (isNearBottomRef.current) {
flatListRef.current?.scrollToEnd({ animated: false });
}
}}
/> />
)} )}
</View> </View>