fix(chat): adapt input bar to soft keyboard in FB in-app browser

FB IAB on Xiaomi (and similar WebViews that don't shrink 100dvh) keeps
the chat input covered by the soft keyboard. Fix by writing the
visualViewport-derived keyboard height to a CSS variable on <html> and
consuming it as padding-bottom on the chat input bar.

- Add useKeyboardHeight hook (visualViewport + rAF batching, no re-renders)
- Apply --keyboard-height + env(safe-area-inset-bottom) to .bar padding
- Add scrollIntoView on textarea focus so the input stays visible
- Add viewportFit: cover so safe-area-inset-bottom is reported
This commit is contained in:
2026-06-24 14:13:01 +08:00
parent 42c03f8901
commit 853ae776f9
6 changed files with 109 additions and 4 deletions
+1
View File
@@ -3,4 +3,5 @@
*/
export * from "./use-breakpoint";
export * from "./use-keyboard-height";
export * from "./use-pull-to-refresh";
+70
View File
@@ -0,0 +1,70 @@
"use client";
/**
* 软键盘高度 hook(修复 Facebook IAB / 小米 WebView 不收缩 100dvh 导致输入框被遮挡)
*
* 通过 window.visualViewport 的 resize/scroll 事件计算键盘高度:
* keyboardHeight = max(0, innerHeight - (visualViewport.height + offsetTop))
* 写到 <html> 的 CSS 变量 --keyboard-height(默认 0px)。
* 不返回 React state,避免键盘动画期间触发 re-render。
* rAF 合并写入;卸载时清理监听并把变量重置为 0px,防止路由切换后状态泄漏。
*
* 行为:
* - iOS Safari / 现代 Chrome AndroidvisualViewport.height 随键盘收缩,差值 ≈ 0,
* padding 不变,无视觉副作用。
* - FB IAB / 小米等不收缩 innerHeight 的 WebView:差值即为键盘高度,CSS 即可适配。
* - visualViewport 不可用:降级为 0(保留原行为,不引入新问题)。
*/
import { useEffect } from "react";
const VAR_NAME = "--keyboard-height";
function compute(): number {
if (typeof window === "undefined") return 0;
const vv = window.visualViewport;
if (vv) {
return Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
}
return 0;
}
export function useKeyboardHeight(): void {
useEffect(() => {
if (typeof window === "undefined") return;
let rafId: number | null = null;
let last = -1;
const apply = () => {
rafId = null;
const h = Math.round(compute());
if (h === last) return;
last = h;
document.documentElement.style.setProperty(VAR_NAME, `${h}px`);
};
const schedule = () => {
if (rafId != null) return;
rafId = window.requestAnimationFrame(apply);
};
const vv = window.visualViewport;
if (vv) {
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
} else {
window.addEventListener("resize", schedule);
}
apply();
return () => {
if (rafId != null) window.cancelAnimationFrame(rafId);
if (vv) {
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
} else {
window.removeEventListener("resize", schedule);
}
document.documentElement.style.setProperty(VAR_NAME, "0px");
};
}, []);
}