fix(chat): simplify Xiaomi Facebook keyboard lift

This commit is contained in:
2026-07-01 16:13:46 +08:00
parent 854a7498b7
commit 94fa3e9b7a
11 changed files with 199 additions and 438 deletions
+190 -182
View File
@@ -1,29 +1,33 @@
"use client";
/**
* 软键盘避让 hook。
* 小米 Android + Facebook IAB 专用输入框避让 hook。
*
* 目标环境:小米系 Android 设备上的 Facebook IAB。
* 这不是通用软键盘方案。它只解决一个已知 WebView 问题:
* 输入框 focus 后键盘弹出,但 Facebook IAB 没有稳定调整页面 viewport
* 导致底部输入框被键盘遮挡。
*
* 策略:
* 1. 持续写入 --app-viewport-height,聊天页高度不再只依赖 100dvh
* 2. 优先使用 visualViewport 计算键盘高度
* 3. 输入框聚焦后测量目标元素是否越过可见底部,计算额外抬升。
* 4. 小米系 Android + Facebook IAB 若 viewport 未可靠变化,启用保守 fallback 抬升
* 键盘收起后若焦点仍停留在 textarea,也要主动释放 fallback。
* - 非小米 Android Facebook IAB:完全不生效
* - focus 后短期多次测量输入框是否被 visibleViewport 遮挡
* - 若 viewport 没有可靠变化,则使用固定 fallback 抬升。
* - 聚焦期间轻量轮询;一旦检测到 viewport 恢复,主动 blur 并清除抬升
*/
import { useEffect, useRef } from "react";
import { Logger } from "@/utils";
import {
RECHECK_DELAYS_MS,
RECHECK_EVENT,
FALLBACK_INPUT_LIFT_PX,
FOCUS_GAP_PX,
KEYBOARD_MEASURE_DELAYS_MS,
KEYBOARD_POLL_INTERVAL_MS,
KEYBOARD_RELEASE_GRACE_MS,
KEYBOARD_VISIBLE_RATIO,
KEYBOARD_VISIBLE_THRESHOLD_PX,
} from "./constants";
import {
resetKeyboardVars,
resetInputLiftVar,
setInputLiftVar,
setKeyboardHeightVar,
setViewportHeightVar,
} from "./css-vars";
import {
getKeyboardAdaptEnvironment,
@@ -33,59 +37,35 @@ import {
blurKeyboardTarget,
isKeyboardTargetActive,
} from "./keyboard-target";
import { addKeyboardRecheckListeners } from "./keyboard-event-listeners";
import { isPossibleKeyboardCloseEvent } from "./release-events";
import type { UseKeyboardHeightOptions, ViewportMetrics } from "./types";
import {
computeFallbackLift,
computeTargetOverlap,
formatMetrics,
getFocusedDurationMs,
getLiftSource,
getReleaseSkipReason,
readViewportMetrics,
shouldReleaseKeyboardState,
} from "./viewport-metrics";
import type { UseKeyboardHeightOptions } from "./types";
const log = new Logger("HooksUseKeyboardHeight");
interface KeyboardSnapshot {
layoutHeight: number;
visibleHeight: number;
visibleBottom: number;
keyboardHeight: number;
keyboardVisible: boolean;
}
export function useKeyboardHeight({
targetRef,
active = false,
onKeyboardDismiss,
}: UseKeyboardHeightOptions = {}): void {
const activeRef = useRef(active);
const onKeyboardDismissRef = useRef(onKeyboardDismiss);
const fallbackAllowedRef = useRef(false);
const focusedAtRef = useRef(0);
useEffect(() => {
onKeyboardDismissRef.current = onKeyboardDismiss;
}, [onKeyboardDismiss]);
useEffect(() => {
activeRef.current = active;
fallbackAllowedRef.current = active;
focusedAtRef.current = active ? performance.now() : 0;
window.dispatchEvent(new Event(RECHECK_EVENT));
}, [active]);
useEffect(() => {
if (typeof window === "undefined") return;
let rafId: number | null = null;
let timeoutIds: number[] = [];
let lastKeyboardHeight = -1;
let lastViewportHeight = -1;
let lastInputLift = -1;
let lastMayBeKeyboardClose = false;
let fallbackWasApplied = false;
let lastEventType = "initial";
const environment = getKeyboardAdaptEnvironment();
log.debug("[keyboard] environment", environment);
if (!shouldEnableKeyboardAdaptation(environment)) {
resetKeyboardVars();
resetInputLiftVar();
log.debug("[keyboard] disabled", {
reason: "not-xiaomi-facebook-iab",
environment,
@@ -93,159 +73,187 @@ export function useKeyboardHeight({
return;
}
const apply = () => {
rafId = null;
let rafId: number | null = null;
let pollId: number | null = null;
let timeoutIds: number[] = [];
let released = false;
let keyboardWasDetected = false;
let lastInputLift = -1;
const focusedAt = active ? performance.now() : 0;
const metrics = readViewportMetrics();
let activeNow = isKeyboardTargetActive(
targetRef?.current,
activeRef.current,
);
const shouldTryRelease =
activeNow &&
lastMayBeKeyboardClose &&
shouldReleaseKeyboardState(metrics, focusedAtRef.current);
log.debug("[keyboard] environment", environment);
if (activeNow && lastMayBeKeyboardClose && !shouldTryRelease) {
log.debug("[keyboard] release skipped", {
reason: getReleaseSkipReason(metrics, focusedAtRef.current),
lastEventType,
environment,
active: activeNow,
reactActive: activeRef.current,
fallbackAllowed: fallbackAllowedRef.current,
lastInputLift,
fallbackWasApplied,
focusedForMs: getFocusedDurationMs(focusedAtRef.current),
metrics: formatMetrics(metrics),
});
}
if (shouldTryRelease) {
log.debug("[keyboard] release confirmed", {
lastEventType,
environment,
active: activeNow,
reactActive: activeRef.current,
fallbackAllowed: fallbackAllowedRef.current,
lastInputLift,
fallbackWasApplied,
focusedForMs: getFocusedDurationMs(focusedAtRef.current),
metrics: formatMetrics(metrics),
});
releaseKeyboardState("keyboard-close-detected", metrics);
activeNow = false;
}
lastMayBeKeyboardClose = false;
const keyboardHeight = Math.round(metrics.keyboardHeight);
const viewportHeight = Math.round(metrics.visibleHeight);
const fallbackLift = fallbackAllowedRef.current
? computeFallbackLift(metrics)
: 0;
const inputLift = Math.round(
activeNow
? Math.max(
computeTargetOverlap(targetRef?.current, metrics.visibleBottom),
fallbackLift,
)
: 0,
);
fallbackWasApplied =
fallbackLift > 0 &&
inputLift >= fallbackLift &&
fallbackAllowedRef.current;
if (keyboardHeight !== lastKeyboardHeight) {
lastKeyboardHeight = keyboardHeight;
setKeyboardHeightVar(keyboardHeight);
}
if (viewportHeight !== lastViewportHeight) {
lastViewportHeight = viewportHeight;
setViewportHeightVar(viewportHeight);
}
if (inputLift !== lastInputLift) {
lastInputLift = inputLift;
setInputLiftVar(inputLift);
log.debug("[keyboard] apply", {
environment,
active: activeNow,
reactActive: activeRef.current,
inputLift,
fallbackLift,
fallbackAllowed: fallbackAllowedRef.current,
source: getLiftSource(inputLift, fallbackLift),
metrics: formatMetrics(metrics),
});
}
};
const schedule = () => {
if (rafId != null) return;
rafId = window.requestAnimationFrame(apply);
};
const scheduleRechecks = (event?: Event) => {
lastEventType = event?.type ?? "manual";
const hadInputLift = fallbackWasApplied || lastInputLift > 0;
const possibleKeyboardClose =
event != null && isPossibleKeyboardCloseEvent(event, hadInputLift);
log.debug("[keyboard] schedule rechecks", {
eventType: lastEventType,
possibleKeyboardClose,
hadInputLift,
fallbackWasApplied,
lastInputLift,
active: activeRef.current,
fallbackAllowed: fallbackAllowedRef.current,
focusedForMs: getFocusedDurationMs(focusedAtRef.current),
});
if (possibleKeyboardClose) {
lastMayBeKeyboardClose = true;
log.debug("[keyboard] possible close detected", {
eventType: lastEventType,
hadInputLift,
fallbackWasApplied,
lastInputLift,
focusedForMs: getFocusedDurationMs(focusedAtRef.current),
});
}
const clearTimers = () => {
timeoutIds.forEach((id) => window.clearTimeout(id));
timeoutIds = RECHECK_DELAYS_MS.map((delay) =>
window.setTimeout(schedule, delay),
);
timeoutIds = [];
if (pollId != null) {
window.clearInterval(pollId);
pollId = null;
}
};
const releaseKeyboardState = (
reason: string,
metrics: ViewportMetrics,
): void => {
activeRef.current = false;
fallbackAllowedRef.current = false;
focusedAtRef.current = 0;
const applyLift = (lift: number, reason: string) => {
const nextLift = Math.max(0, Math.round(lift));
if (nextLift === lastInputLift) return;
lastInputLift = nextLift;
setInputLiftVar(nextLift);
log.debug("[keyboard] apply", {
reason,
inputLift: nextLift,
fallback: nextLift === FALLBACK_INPUT_LIFT_PX,
snapshot: formatSnapshot(readKeyboardSnapshot()),
});
};
const release = (reason: string) => {
if (released) return;
released = true;
clearTimers();
applyLift(0, reason);
blurKeyboardTarget(targetRef?.current);
onKeyboardDismissRef.current?.();
log.debug("[keyboard] released", {
reason,
environment,
metrics: formatMetrics(metrics),
snapshot: formatSnapshot(readKeyboardSnapshot()),
});
};
const removeKeyboardRecheckListeners =
addKeyboardRecheckListeners(scheduleRechecks);
const measure = (reason: string) => {
rafId = null;
scheduleRechecks();
const target = targetRef?.current;
const activeNow = isKeyboardTargetActive(target, active);
const snapshot = readKeyboardSnapshot();
if (snapshot.keyboardVisible) keyboardWasDetected = true;
if (!activeNow) {
release("target-inactive");
return;
}
const focusedFor = focusedAt > 0 ? performance.now() - focusedAt : 0;
if (
keyboardWasDetected &&
!snapshot.keyboardVisible &&
focusedFor >= KEYBOARD_RELEASE_GRACE_MS
) {
release("viewport-restored");
return;
}
const overlapLift = computeTargetOverlap(target, snapshot.visibleBottom);
const fallbackLift = snapshot.keyboardVisible ? 0 : FALLBACK_INPUT_LIFT_PX;
applyLift(Math.max(overlapLift, fallbackLift), reason);
};
const scheduleMeasure = (reason: string) => {
if (rafId != null) return;
rafId = window.requestAnimationFrame(() => measure(reason));
};
const scheduleInitialMeasures = () => {
timeoutIds = KEYBOARD_MEASURE_DELAYS_MS.map((delay) =>
window.setTimeout(() => scheduleMeasure(`focus-${delay}`), delay),
);
};
const handleViewportSignal = () => {
scheduleMeasure("viewport-signal");
};
const handleFocusOut = () => {
release("focusout");
};
const handlePointerDown = (event: PointerEvent | TouchEvent) => {
const target = targetRef?.current;
if (!target || event.target === target) return;
if (event.target instanceof Node && target.contains(event.target)) return;
release("outside-interaction");
};
if (!active) {
release("react-inactive");
return;
}
const visualViewport = window.visualViewport;
visualViewport?.addEventListener("resize", handleViewportSignal);
visualViewport?.addEventListener("scroll", handleViewportSignal);
window.addEventListener("resize", handleViewportSignal);
window.addEventListener("orientationchange", handleViewportSignal);
window.addEventListener("focusout", handleFocusOut);
window.addEventListener("blur", handleFocusOut);
document.addEventListener("visibilitychange", handleViewportSignal);
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("touchstart", handlePointerDown, true);
scheduleInitialMeasures();
pollId = window.setInterval(
() => scheduleMeasure("poll"),
KEYBOARD_POLL_INTERVAL_MS,
);
return () => {
if (rafId != null) window.cancelAnimationFrame(rafId);
timeoutIds.forEach((id) => window.clearTimeout(id));
removeKeyboardRecheckListeners();
resetKeyboardVars();
clearTimers();
visualViewport?.removeEventListener("resize", handleViewportSignal);
visualViewport?.removeEventListener("scroll", handleViewportSignal);
window.removeEventListener("resize", handleViewportSignal);
window.removeEventListener("orientationchange", handleViewportSignal);
window.removeEventListener("focusout", handleFocusOut);
window.removeEventListener("blur", handleFocusOut);
document.removeEventListener("visibilitychange", handleViewportSignal);
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("touchstart", handlePointerDown, true);
resetInputLiftVar();
};
}, [targetRef]);
}, [
active,
targetRef,
]);
}
function readKeyboardSnapshot(): KeyboardSnapshot {
const layoutHeight = window.innerHeight;
const visualViewport = window.visualViewport;
const visibleHeight = visualViewport?.height ?? layoutHeight;
const visibleBottom = visualViewport
? visualViewport.offsetTop + visualViewport.height
: layoutHeight;
const keyboardHeight = Math.max(0, layoutHeight - visibleBottom);
const keyboardVisible =
keyboardHeight > KEYBOARD_VISIBLE_THRESHOLD_PX ||
visibleHeight < layoutHeight * KEYBOARD_VISIBLE_RATIO;
return {
layoutHeight,
visibleHeight,
visibleBottom,
keyboardHeight,
keyboardVisible,
};
}
function computeTargetOverlap(
target: HTMLElement | null | undefined,
visibleBottom: number,
): number {
if (!target) return 0;
const rect = target.getBoundingClientRect();
return Math.max(0, rect.bottom + FOCUS_GAP_PX - visibleBottom);
}
function formatSnapshot(snapshot: KeyboardSnapshot) {
return {
layoutHeight: Math.round(snapshot.layoutHeight),
visibleHeight: Math.round(snapshot.visibleHeight),
visibleBottom: Math.round(snapshot.visibleBottom),
keyboardHeight: Math.round(snapshot.keyboardHeight),
keyboardVisible: snapshot.keyboardVisible,
};
}
export type { UseKeyboardHeightOptions };