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:
parent
55e3cdfb26
commit
49e558902b
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user