Fix mobile keyboard pushing chat off-screen with visualViewport and body scroll lock

This commit is contained in:
2026-03-11 21:53:44 -07:00
parent cf80bc1445
commit 75d53716fd
+53 -22
View File
@@ -32,6 +32,7 @@ export default function Chat() {
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [viewportHeight, setViewportHeight] = useState<number | undefined>(undefined);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -52,6 +53,46 @@ export default function Chat() {
messagesEndRef.current?.scrollIntoView({ behavior });
}, []);
// Lock body scroll and track visual viewport height for mobile keyboard
useEffect(() => {
const html = document.documentElement;
const body = document.body;
html.style.overflow = 'hidden';
html.style.height = '100%';
body.style.overflow = 'hidden';
body.style.height = '100%';
const vv = window.visualViewport;
if (vv) {
const update = () => {
setViewportHeight(vv.height);
// Scroll page back to top in case browser scrolled it
window.scrollTo(0, 0);
if (isAtBottom.current) {
requestAnimationFrame(() => scrollToBottom('instant'));
}
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', () => window.scrollTo(0, 0));
return () => {
html.style.overflow = '';
html.style.height = '';
body.style.overflow = '';
body.style.height = '';
vv.removeEventListener('resize', update);
};
}
return () => {
html.style.overflow = '';
html.style.height = '';
body.style.overflow = '';
body.style.height = '';
};
}, [scrollToBottom]);
useEffect(() => {
if (authLoading) return;
if (!user) {
@@ -77,22 +118,6 @@ export default function Chat() {
}
}, [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();
if (!input.trim() || !friendId || sending) return;
@@ -120,11 +145,17 @@ export default function Chat() {
if (!user) return null;
// Use visualViewport height when available (handles mobile keyboard),
// subtract header height (3.5rem = 56px)
const containerStyle: React.CSSProperties = viewportHeight
? { height: viewportHeight - 56 }
: { height: 'calc(100dvh - 3.5rem)' };
return (
<PageLayout noPadding>
<div className="flex h-full flex-col">
{/* 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">
<div className="flex flex-col" style={containerStyle}>
{/* Chat header */}
<div className="flex shrink-0 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" />
@@ -143,7 +174,7 @@ export default function Chat() {
</div>
</div>
{/* Messages - scrollable area fills remaining space */}
{/* Messages */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
@@ -186,8 +217,8 @@ export default function Chat() {
<div ref={messagesEndRef} />
</div>
{/* Input - pinned to bottom */}
<form onSubmit={handleSend} className="flex gap-2 border-t border-border bg-background px-4 py-3">
{/* Input */}
<form onSubmit={handleSend} className="flex shrink-0 gap-2 border-t border-border bg-background px-4 py-3">
<Input
placeholder="Type a message..."
value={input}