fix(chat): scope keyboard avoidance to Facebook IAB

This commit is contained in:
2026-07-16 16:41:02 +08:00
parent aa801a3e18
commit 514297f469
4 changed files with 55 additions and 23 deletions
@@ -38,7 +38,7 @@ describe("resolveChatKeyboardInset", () => {
});
});
it("uses the visual viewport occlusion without an extra gap", () => {
it("uses the visual viewport occlusion with the target gap", () => {
expect(
resolveChatKeyboardInset(
baseline,
@@ -46,7 +46,7 @@ describe("resolveChatKeyboardInset", () => {
),
).toEqual({
keyboardVisible: true,
bottomInset: 300,
bottomInset: 308,
source: "visual-viewport",
});
});
@@ -62,7 +62,7 @@ describe("resolveChatKeyboardInset", () => {
),
).toEqual({
keyboardVisible: true,
bottomInset: 320,
bottomInset: 328,
source: "virtual-keyboard",
});
});
@@ -83,8 +83,8 @@ describe("resolveChatKeyboardInset", () => {
describe("resolveChatKeyboardFallbackInset", () => {
it("scales with viewport height and remains bounded", () => {
expect(resolveChatKeyboardFallbackInset(500)).toBe(260);
expect(resolveChatKeyboardFallbackInset(800)).toBe(336);
expect(resolveChatKeyboardFallbackInset(1_200)).toBe(380);
expect(resolveChatKeyboardFallbackInset(500)).toBe(268);
expect(resolveChatKeyboardFallbackInset(800)).toBe(344);
expect(resolveChatKeyboardFallbackInset(1_200)).toBe(388);
});
});
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BrowserDetector } from "@/utils/browser-detect";
import { PlatformDetector } from "@/utils/platform-detect";
import { useChatKeyboardAvoidance } from "../use-chat-keyboard-avoidance";
@@ -49,6 +50,7 @@ describe("useChatKeyboardAvoidance", () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(true);
vi.mocked(PlatformDetector.isAndroid).mockReturnValue(true);
innerHeight = 800;
visualViewport = new MockVisualViewport();
virtualKeyboard = new MockVirtualKeyboard();
@@ -96,16 +98,36 @@ describe("useChatKeyboardAvoidance", () => {
expect(getBottomInset(container)).toBe("");
act(() => vi.advanceTimersByTime(1));
expect(getBottomInset(container)).toBe("336px");
expect(getBottomInset(container)).toBe("344px");
expect(intervalSpy).not.toHaveBeenCalled();
});
it("does not use fallback outside Facebook's in-app browser", () => {
it("does not run any avoidance outside Facebook's in-app browser", () => {
vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(false);
renderHarness(root, false);
renderHarness(root, true);
act(() => vi.advanceTimersByTime(250));
visualViewport.height = 460;
virtualKeyboard.boundingRect.height = 320;
act(() => {
visualViewport.dispatchEvent(new Event("resize"));
virtualKeyboard.dispatchEvent(new Event("geometrychange"));
vi.advanceTimersByTime(250);
});
expect(getBottomInset(container)).toBe("");
});
it("does not run avoidance for iOS Facebook", () => {
vi.mocked(PlatformDetector.isAndroid).mockReturnValue(false);
renderHarness(root, false);
renderHarness(root, true);
visualViewport.height = 460;
act(() => {
visualViewport.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(250);
});
expect(getBottomInset(container)).toBe("");
});
@@ -119,7 +141,7 @@ describe("useChatKeyboardAvoidance", () => {
visualViewport.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(0);
});
expect(getBottomInset(container)).toBe("300px");
expect(getBottomInset(container)).toBe("308px");
visualViewport.height = 760;
act(() => {
@@ -129,7 +151,7 @@ describe("useChatKeyboardAvoidance", () => {
expect(getBottomInset(container)).toBe("0px");
});
it("uses Virtual Keyboard geometry without an extra gap", () => {
it("uses Virtual Keyboard geometry with the target gap", () => {
renderHarness(root, false);
renderHarness(root, true);
@@ -139,7 +161,7 @@ describe("useChatKeyboardAvoidance", () => {
vi.advanceTimersByTime(0);
});
expect(getBottomInset(container)).toBe("320px");
expect(getBottomInset(container)).toBe("328px");
});
it("does not add fallback when the layout viewport resized", () => {
@@ -160,7 +182,7 @@ describe("useChatKeyboardAvoidance", () => {
renderHarness(root, false);
renderHarness(root, true);
act(() => vi.advanceTimersByTime(250));
expect(getBottomInset(container)).toBe("336px");
expect(getBottomInset(container)).toBe("344px");
visualViewport.height = 460;
act(() => {
@@ -168,14 +190,14 @@ describe("useChatKeyboardAvoidance", () => {
vi.advanceTimersByTime(0);
});
expect(getBottomInset(container)).toBe("300px");
expect(getBottomInset(container)).toBe("308px");
});
it("removes the local inset when focus leaves", () => {
renderHarness(root, false);
renderHarness(root, true);
act(() => vi.advanceTimersByTime(250));
expect(getBottomInset(container)).toBe("336px");
expect(getBottomInset(container)).toBe("344px");
renderHarness(root, false);
+8 -2
View File
@@ -1,5 +1,6 @@
export const CHAT_KEYBOARD_VISIBLE_THRESHOLD_PX = 80;
export const CHAT_KEYBOARD_ZOOM_THRESHOLD = 1.05;
export const CHAT_KEYBOARD_TARGET_GAP_PX = 8;
export const CHAT_KEYBOARD_FALLBACK_RATIO = 0.42;
export const CHAT_KEYBOARD_FALLBACK_MIN_PX = 260;
export const CHAT_KEYBOARD_FALLBACK_MAX_PX = 380;
@@ -67,7 +68,10 @@ export function resolveChatKeyboardInset(
return {
keyboardVisible: true,
bottomInset: Math.min(maxInset, Math.round(occludedHeight)),
bottomInset: Math.min(
maxInset,
Math.round(occludedHeight + CHAT_KEYBOARD_TARGET_GAP_PX),
),
source:
virtualKeyboardHeight >= visualViewportHeight
? "virtual-keyboard"
@@ -78,11 +82,13 @@ export function resolveChatKeyboardInset(
export function resolveChatKeyboardFallbackInset(
layoutHeight: number,
): number {
return Math.min(
const estimatedKeyboardHeight = Math.min(
CHAT_KEYBOARD_FALLBACK_MAX_PX,
Math.max(
CHAT_KEYBOARD_FALLBACK_MIN_PX,
Math.round(layoutHeight * CHAT_KEYBOARD_FALLBACK_RATIO),
),
);
return estimatedKeyboardHeight + CHAT_KEYBOARD_TARGET_GAP_PX;
}
@@ -44,7 +44,7 @@ export function useChatKeyboardAvoidance({
}, [active]);
useEffect(() => {
if (typeof window === "undefined" || !PlatformDetector.isAndroid()) {
if (typeof window === "undefined" || !isKeyboardAvoidanceEnabled()) {
return;
}
@@ -72,16 +72,16 @@ export function useChatKeyboardAvoidance({
const container = containerRef.current;
if (typeof window === "undefined" || !container) return;
if (!active || !PlatformDetector.isAndroid()) {
const enabled = isKeyboardAvoidanceEnabled();
if (!active || !enabled) {
clearBottomInset(container);
if (!active) baselineRef.current = readViewportBaseline();
if (!active && enabled) 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;
@@ -194,8 +194,6 @@ export function useChatKeyboardAvoidance({
applyBottomInset(resolution.bottomInset, resolution.source);
return;
}
if (!fallbackAllowed) return;
fallbackApplied = true;
applyBottomInset(
resolveChatKeyboardFallbackInset(baseline.layoutHeight),
@@ -246,3 +244,9 @@ function getVirtualKeyboard(): VirtualKeyboardLike | null {
function clearBottomInset(container: HTMLElement): void {
container.style.removeProperty(CHAT_KEYBOARD_BOTTOM_INSET_VAR);
}
function isKeyboardAvoidanceEnabled(): boolean {
return (
PlatformDetector.isAndroid() && BrowserDetector.isFacebookInAppBrowser()
);
}