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 flatListRef = useRef<FlatListType<ChatMsg>>(null);
const isNearBottomRef = useRef(true);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
@ -163,6 +162,7 @@ export default function DmScreen() {
}));
setMessages(msgs);
requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false }));
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 100);
return data;
} catch (err: any) {
console.error('[dm] history fetch failed:', err?.message ?? err);
@ -174,12 +174,10 @@ export default function DmScreen() {
gcTime: 0,
});
// Neue Nachricht (incoming Realtime oder outgoing send) — nur scrollen wenn nahe unten
// Neue Nachricht (incoming Realtime oder outgoing send) → immer scrollen
useEffect(() => {
if (messages.length === 0) return;
if (isNearBottomRef.current) {
requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: true }));
}
requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: true }));
}, [messages.length]);
// Realtime: neue DMs vom Partner
@ -234,13 +232,12 @@ export default function DmScreen() {
}
}
async function uploadAttachment(): Promise<{ url: string; type: string; name: string } | null> {
if (!attachment) return null;
async function uploadAttachment(file: { uri: string; name: string }): Promise<{ url: string; type: string; name: string } | null> {
try {
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 base64 = await FileSystem.readAsStringAsync(attachment.uri, {
const base64 = await FileSystem.readAsStringAsync(file.uri, {
encoding: FileSystem.EncodingType.Base64,
});
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;
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) {
Alert.alert(t('chat.upload_failed'), err?.message ?? '');
return null;
@ -266,58 +263,91 @@ export default function DmScreen() {
const content = inputText.trim();
if (!content && !attachment) 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);
try {
let attachmentMeta: { url: string; type: string; name: string } | null = null;
if (attachment) {
attachmentMeta = await uploadAttachment();
if (!attachmentMeta) { setSending(false); return; }
if (currentAttachment) {
attachmentMeta = await uploadAttachment(currentAttachment);
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', {
method: 'POST',
body: {
receiverId: userId,
content,
replyToId: replyTo?.id,
replyToId: currentReplyTo?.id,
attachmentUrl: attachmentMeta?.url,
attachmentType: attachmentMeta?.type,
attachmentName: attachmentMeta?.name,
},
});
setMessages((prev) => [
...prev,
{
id: newMsg.id,
userId: myUserId ?? '',
nickname: 'Du',
avatar: null,
content: newMsg.content,
replyTo: newMsg.replyTo
setMessages((prev) =>
prev.map((m) =>
m.id === tempId
? {
id: newMsg.replyTo.id,
userId: newMsg.replyTo.senderId,
nickname:
newMsg.replyTo.senderId === myUserId ? 'Du' : partner?.nickname ?? '?',
content: newMsg.replyTo.content?.slice(0, 100) ?? '',
attachmentType: newMsg.replyTo.attachmentType ?? null,
...m,
id: newMsg.id,
content: newMsg.content,
attachmentUrl: newMsg.attachmentUrl ?? null,
attachmentType: newMsg.attachmentType ?? null,
attachmentName: newMsg.attachmentName ?? null,
createdAt: newMsg.createdAt,
status: 'sent' as const,
}
: null,
attachmentUrl: newMsg.attachmentUrl,
attachmentType: newMsg.attachmentType,
attachmentName: newMsg.attachmentName,
likesCount: newMsg.likesCount ?? 0,
likedByMe: false,
createdAt: newMsg.createdAt,
isOwn: true,
readAt: null,
},
]);
setInputText('');
setAttachment(null);
setReplyTo(null);
: m,
),
);
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
} catch (err) {
console.error('DM send failed:', err);
setMessages((prev) =>
prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)),
);
} finally {
setSending(false);
}
@ -491,17 +521,7 @@ export default function DmScreen() {
showsVerticalScrollIndicator={false}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
onScroll={(e) => {
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 });
}
}}
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })}
/>
)}
</View>