Fix chat layout for mobile keyboard with dvh, sticky header/input, and smart scroll

This commit is contained in:
2026-03-11 21:44:25 -07:00
parent a6bcdd5aa9
commit 303935c963
+45 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useParams, Link, useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout';
import { Button } from '@/components/ui/button';
@@ -36,9 +36,22 @@ export default function Chat() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const lastTypingEmit = useRef(0);
const isAtBottom = useRef(true);
const friend = friends.find((f) => f.friend.id === friendId)?.friend;
const checkIfAtBottom = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const threshold = 50;
isAtBottom.current =
container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
}, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
messagesEndRef.current?.scrollIntoView({ behavior });
}, []);
useEffect(() => {
if (authLoading) return;
if (!user) {
@@ -57,9 +70,28 @@ export default function Chat() {
};
}, [user, authLoading, friendId, navigate, connect, disconnect, openConversation, clearChat]);
// Scroll to bottom on new messages if user hasn't scrolled up
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
if (isAtBottom.current) {
scrollToBottom();
}
}, [messages, scrollToBottom]);
// Handle visual viewport resize (mobile keyboard open/close)
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const handleResize = () => {
if (isAtBottom.current) {
// Use instant scroll when keyboard animates to avoid janky smooth scroll
scrollToBottom('instant');
}
};
vv.addEventListener('resize', handleResize);
return () => vv.removeEventListener('resize', handleResize);
}, [scrollToBottom]);
async function handleSend(e: React.FormEvent) {
e.preventDefault();
@@ -69,6 +101,7 @@ export default function Chat() {
try {
sendMessage(friendId, input.trim());
setInput('');
isAtBottom.current = true;
} catch {
toast.error('Failed to send message');
} finally {
@@ -77,6 +110,7 @@ export default function Chat() {
}
function handleScroll() {
checkIfAtBottom();
const container = scrollContainerRef.current;
if (!container || !hasMore || loading) return;
if (container.scrollTop === 0) {
@@ -87,10 +121,10 @@ export default function Chat() {
if (!user) return null;
return (
<PageLayout>
<div className="flex h-[calc(100vh-8rem)] flex-col">
{/* Header */}
<div className="flex items-center gap-3 border-b border-border pb-3">
<PageLayout noPadding>
<div className="flex flex-col" style={{ height: 'calc(100dvh - 3.5rem)' }}>
{/* Chat header - sticky at top */}
<div className="sticky top-0 z-10 flex items-center gap-3 border-b border-border bg-background px-4 py-3">
<Link to="/friends">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
@@ -109,11 +143,11 @@ export default function Chat() {
</div>
</div>
{/* Messages */}
{/* Messages - scrollable area fills remaining space */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto py-4 space-y-2"
className="flex-1 overflow-y-auto px-4 py-4 space-y-2"
>
{loading && (
<div className="flex justify-center py-2">
@@ -152,8 +186,8 @@ export default function Chat() {
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={handleSend} className="flex gap-2 border-t border-border pt-3">
{/* Input - pinned to bottom */}
<form onSubmit={handleSend} className="flex gap-2 border-t border-border bg-background px-4 py-3">
<Input
placeholder="Type a message..."
value={input}