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
+6
View File
@@ -5,6 +5,7 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context";
import { useKeyboardHeight } from "@/hooks/use-keyboard-height";
import type { LoginStatus } from "@/data/dto/auth";
import { AppStorage } from "@/data/storage/app/app_storage";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
@@ -42,6 +43,11 @@ export function ChatScreen() {
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
// 全局副作用:把软键盘高度写到 <html> 的 --keyboard-height CSS 变量,
// 让 .bar 的 padding-bottom 跟着调整,修复 FB IAB / 小米 WebView 输入框被键盘遮挡的问题。
// 详见 src/hooks/use-keyboard-height.ts
useKeyboardHeight();
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
@@ -1,12 +1,21 @@
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
/* 外层:focused 时加 padding-top + 粉色背景 + 阴影 */
/* 键盘适配(修复 FB IAB / 小米 WebView 不收缩 100dvh 导致输入框被遮挡):
* --keyboard-height 由 src/hooks/use-keyboard-height.ts 写到 <html>,默认 0px。
* env(safe-area-inset-bottom) 处理小米全面屏手势条 / iPhone home indicator。
* 二者在原生已正确处理键盘的浏览器(iOS Safari、现代 Chrome Android)上都为 0
* 因此无视觉副作用。 */
.bar {
flex: 0 0 auto;
padding: 0 var(--spacing-lg, 16px) var(--spacing-lg, 16px);
padding: 0
var(--spacing-lg, 16px)
calc(
var(--spacing-lg, 16px) + var(--keyboard-height, 0px) +
env(safe-area-inset-bottom, 0px)
);
background: transparent;
transition: padding-top 0.2s ease, background-color 0.2s ease,
box-shadow 0.2s ease;
transition: padding-top 0.2s ease, padding-bottom 0.2s ease,
background-color 0.2s ease, box-shadow 0.2s ease;
}
.barFocused {
@@ -18,6 +18,7 @@ import {
forwardRef,
type FormEvent,
type KeyboardEvent,
useEffect,
useImperativeHandle,
useRef,
} from "react";
@@ -54,6 +55,20 @@ export const ChatInputTextField = forwardRef<
// 对外暴露与 forwardRef 同名的 ref(指向内部 textarea 节点)
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement, []);
// 获得焦点时把 textarea 滚到可见区域(修复小米/FB IAB 键盘弹起后
// 内容仍被遮挡的场景),rAF 等浏览器完成键盘布局。
useEffect(() => {
const el = innerRef.current;
if (!el) return;
const onFocus = () => {
window.requestAnimationFrame(() => {
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
});
};
el.addEventListener("focus", onFocus);
return () => el.removeEventListener("focus", onFocus);
}, []);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return;
if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行
+4
View File
@@ -32,6 +32,10 @@ export const metadata: Metadata = {
export const viewport: Viewport = {
// 浏览器顶栏 / Android PWA 顶栏颜色
themeColor: "#f84d96",
// 让页面延伸到 notch / home indicator 区域,
// env(safe-area-inset-bottom) 才能取到非零值(见 chat-input-bar.module.css)。
// Next.js 16 的 Viewport 类型未列出此键,但 generate-viewport.md 允许附加原始 meta 值。
viewportFit: "cover",
};
export default function RootLayout({
+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");
};
}, []);
}