fix(chat): restore Facebook keyboard avoidance

This commit is contained in:
2026-07-16 16:28:10 +08:00
parent 9d04176e90
commit 54b9031622
10 changed files with 642 additions and 121 deletions
@@ -0,0 +1,248 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
import { BrowserDetector } from "@/utils/browser-detect";
import { Logger } from "@/utils/logger";
import { PlatformDetector } from "@/utils/platform-detect";
import {
resolveChatKeyboardFallbackInset,
resolveChatKeyboardInset,
type ChatKeyboardInsetResolution,
type ChatKeyboardViewportBaseline,
type ChatKeyboardViewportSnapshot,
} from "./chat-keyboard-geometry";
const CHAT_KEYBOARD_BOTTOM_INSET_VAR = "--chat-keyboard-bottom-inset";
const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 250;
const log = new Logger("UseChatKeyboardAvoidance");
interface VirtualKeyboardLike extends EventTarget {
readonly boundingRect?: Pick<DOMRectReadOnly, "height">;
}
interface NavigatorWithVirtualKeyboard extends Navigator {
readonly virtualKeyboard?: VirtualKeyboardLike;
}
export interface UseChatKeyboardAvoidanceOptions {
active: boolean;
containerRef: RefObject<HTMLElement | null>;
}
export function useChatKeyboardAvoidance({
active,
containerRef,
}: UseChatKeyboardAvoidanceOptions): void {
const activeRef = useRef(active);
const baselineRef = useRef<ChatKeyboardViewportBaseline | null>(null);
useEffect(() => {
activeRef.current = active;
}, [active]);
useEffect(() => {
if (typeof window === "undefined" || !PlatformDetector.isAndroid()) {
return;
}
const updateBaseline = () => {
if (activeRef.current) return;
baselineRef.current = readViewportBaseline();
};
const visualViewport = window.visualViewport;
updateBaseline();
window.addEventListener("resize", updateBaseline);
window.addEventListener("orientationchange", updateBaseline);
visualViewport?.addEventListener("resize", updateBaseline);
visualViewport?.addEventListener("scroll", updateBaseline);
return () => {
window.removeEventListener("resize", updateBaseline);
window.removeEventListener("orientationchange", updateBaseline);
visualViewport?.removeEventListener("resize", updateBaseline);
visualViewport?.removeEventListener("scroll", updateBaseline);
};
}, []);
useEffect(() => {
const container = containerRef.current;
if (typeof window === "undefined" || !container) return;
if (!active || !PlatformDetector.isAndroid()) {
clearBottomInset(container);
if (!active) baselineRef.current = readViewportBaseline();
return;
}
const baseline = baselineRef.current ?? readViewportBaseline();
const visualViewport = window.visualViewport;
const virtualKeyboard = getVirtualKeyboard();
const fallbackAllowed = BrowserDetector.isFacebookInAppBrowser();
let frameId: number | null = null;
let fallbackTimerId: number | null = null;
let lastInset = -1;
let pendingCanDismiss = false;
let pendingReason = "initial";
let keyboardObserved = false;
let fallbackApplied = false;
let sessionDismissed = false;
const applyBottomInset = (
bottomInset: number,
source: ChatKeyboardInsetResolution["source"] | "fallback" | "reset",
) => {
const nextInset = Math.max(0, Math.round(bottomInset));
if (nextInset === lastInset) return;
lastInset = nextInset;
container.style.setProperty(
CHAT_KEYBOARD_BOTTOM_INSET_VAR,
`${nextInset}px`,
);
log.debug("[chat-keyboard] apply", {
source,
bottomInset: nextInset,
});
};
const clearFallbackTimer = () => {
if (fallbackTimerId == null) return;
window.clearTimeout(fallbackTimerId);
fallbackTimerId = null;
};
const measure = (reason: string, canDismiss: boolean) => {
if (sessionDismissed) return;
const resolution = resolveChatKeyboardInset(
baseline,
readViewportSnapshot(),
);
if (resolution.keyboardVisible) {
keyboardObserved = true;
clearFallbackTimer();
applyBottomInset(resolution.bottomInset, resolution.source);
return;
}
if ((keyboardObserved || fallbackApplied) && canDismiss) {
sessionDismissed = true;
clearFallbackTimer();
applyBottomInset(0, "reset");
log.debug("[chat-keyboard] dismissed", { reason });
}
};
const scheduleMeasure = (reason: string, canDismiss: boolean) => {
pendingReason = reason;
pendingCanDismiss ||= canDismiss;
if (frameId != null) return;
frameId = window.requestAnimationFrame(() => {
frameId = null;
const nextReason = pendingReason;
const nextCanDismiss = pendingCanDismiss;
pendingCanDismiss = false;
measure(nextReason, nextCanDismiss);
});
};
const handleVisualResize = () => scheduleMeasure("visual-resize", true);
const handleVisualScroll = () => scheduleMeasure("visual-scroll", false);
const handleWindowResize = () => scheduleMeasure("window-resize", true);
const handleVirtualKeyboardChange = () =>
scheduleMeasure("virtual-keyboard", true);
const resetSession = (reason: string) => {
sessionDismissed = true;
clearFallbackTimer();
applyBottomInset(0, "reset");
log.debug("[chat-keyboard] reset", { reason });
};
const handleOrientationChange = () => resetSession("orientationchange");
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
resetSession("visibilitychange");
}
};
visualViewport?.addEventListener("resize", handleVisualResize);
visualViewport?.addEventListener("scroll", handleVisualScroll);
virtualKeyboard?.addEventListener(
"geometrychange",
handleVirtualKeyboardChange,
);
window.addEventListener("resize", handleWindowResize);
window.addEventListener("orientationchange", handleOrientationChange);
document.addEventListener("visibilitychange", handleVisibilityChange);
scheduleMeasure("focus", false);
fallbackTimerId = window.setTimeout(() => {
fallbackTimerId = null;
if (!activeRef.current || sessionDismissed) return;
const resolution = resolveChatKeyboardInset(
baseline,
readViewportSnapshot(),
);
if (resolution.keyboardVisible) {
keyboardObserved = true;
applyBottomInset(resolution.bottomInset, resolution.source);
return;
}
if (!fallbackAllowed) return;
fallbackApplied = true;
applyBottomInset(
resolveChatKeyboardFallbackInset(baseline.layoutHeight),
"fallback",
);
}, CHAT_KEYBOARD_FALLBACK_DELAY_MS);
return () => {
if (frameId != null) window.cancelAnimationFrame(frameId);
clearFallbackTimer();
visualViewport?.removeEventListener("resize", handleVisualResize);
visualViewport?.removeEventListener("scroll", handleVisualScroll);
virtualKeyboard?.removeEventListener(
"geometrychange",
handleVirtualKeyboardChange,
);
window.removeEventListener("resize", handleWindowResize);
window.removeEventListener("orientationchange", handleOrientationChange);
document.removeEventListener("visibilitychange", handleVisibilityChange);
clearBottomInset(container);
};
}, [active, containerRef]);
}
function readViewportBaseline(): ChatKeyboardViewportBaseline {
return {
layoutHeight: window.innerHeight,
visualHeight: window.visualViewport?.height ?? window.innerHeight,
};
}
function readViewportSnapshot(): ChatKeyboardViewportSnapshot {
const visualViewport = window.visualViewport;
return {
layoutHeight: window.innerHeight,
visualHeight: visualViewport?.height ?? window.innerHeight,
visualScale: visualViewport?.scale ?? 1,
virtualKeyboardHeight: getVirtualKeyboard()?.boundingRect?.height ?? 0,
};
}
function getVirtualKeyboard(): VirtualKeyboardLike | null {
return (
(navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null
);
}
function clearBottomInset(container: HTMLElement): void {
container.style.removeProperty(CHAT_KEYBOARD_BOTTOM_INSET_VAR);
}