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( expect(
resolveChatKeyboardInset( resolveChatKeyboardInset(
baseline, baseline,
@@ -46,7 +46,7 @@ describe("resolveChatKeyboardInset", () => {
), ),
).toEqual({ ).toEqual({
keyboardVisible: true, keyboardVisible: true,
bottomInset: 300, bottomInset: 308,
source: "visual-viewport", source: "visual-viewport",
}); });
}); });
@@ -62,7 +62,7 @@ describe("resolveChatKeyboardInset", () => {
), ),
).toEqual({ ).toEqual({
keyboardVisible: true, keyboardVisible: true,
bottomInset: 320, bottomInset: 328,
source: "virtual-keyboard", source: "virtual-keyboard",
}); });
}); });
@@ -83,8 +83,8 @@ describe("resolveChatKeyboardInset", () => {
describe("resolveChatKeyboardFallbackInset", () => { describe("resolveChatKeyboardFallbackInset", () => {
it("scales with viewport height and remains bounded", () => { it("scales with viewport height and remains bounded", () => {
expect(resolveChatKeyboardFallbackInset(500)).toBe(260); expect(resolveChatKeyboardFallbackInset(500)).toBe(268);
expect(resolveChatKeyboardFallbackInset(800)).toBe(336); expect(resolveChatKeyboardFallbackInset(800)).toBe(344);
expect(resolveChatKeyboardFallbackInset(1_200)).toBe(380); 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BrowserDetector } from "@/utils/browser-detect"; import { BrowserDetector } from "@/utils/browser-detect";
import { PlatformDetector } from "@/utils/platform-detect";
import { useChatKeyboardAvoidance } from "../use-chat-keyboard-avoidance"; import { useChatKeyboardAvoidance } from "../use-chat-keyboard-avoidance";
@@ -49,6 +50,7 @@ describe("useChatKeyboardAvoidance", () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true; .IS_REACT_ACT_ENVIRONMENT = true;
vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(true); vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(true);
vi.mocked(PlatformDetector.isAndroid).mockReturnValue(true);
innerHeight = 800; innerHeight = 800;
visualViewport = new MockVisualViewport(); visualViewport = new MockVisualViewport();
virtualKeyboard = new MockVirtualKeyboard(); virtualKeyboard = new MockVirtualKeyboard();
@@ -96,16 +98,36 @@ describe("useChatKeyboardAvoidance", () => {
expect(getBottomInset(container)).toBe(""); expect(getBottomInset(container)).toBe("");
act(() => vi.advanceTimersByTime(1)); act(() => vi.advanceTimersByTime(1));
expect(getBottomInset(container)).toBe("336px"); expect(getBottomInset(container)).toBe("344px");
expect(intervalSpy).not.toHaveBeenCalled(); 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); vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(false);
renderHarness(root, false); renderHarness(root, false);
renderHarness(root, true); 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(""); expect(getBottomInset(container)).toBe("");
}); });
@@ -119,7 +141,7 @@ describe("useChatKeyboardAvoidance", () => {
visualViewport.dispatchEvent(new Event("resize")); visualViewport.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(0); vi.advanceTimersByTime(0);
}); });
expect(getBottomInset(container)).toBe("300px"); expect(getBottomInset(container)).toBe("308px");
visualViewport.height = 760; visualViewport.height = 760;
act(() => { act(() => {
@@ -129,7 +151,7 @@ describe("useChatKeyboardAvoidance", () => {
expect(getBottomInset(container)).toBe("0px"); 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, false);
renderHarness(root, true); renderHarness(root, true);
@@ -139,7 +161,7 @@ describe("useChatKeyboardAvoidance", () => {
vi.advanceTimersByTime(0); vi.advanceTimersByTime(0);
}); });
expect(getBottomInset(container)).toBe("320px"); expect(getBottomInset(container)).toBe("328px");
}); });
it("does not add fallback when the layout viewport resized", () => { it("does not add fallback when the layout viewport resized", () => {
@@ -160,7 +182,7 @@ describe("useChatKeyboardAvoidance", () => {
renderHarness(root, false); renderHarness(root, false);
renderHarness(root, true); renderHarness(root, true);
act(() => vi.advanceTimersByTime(250)); act(() => vi.advanceTimersByTime(250));
expect(getBottomInset(container)).toBe("336px"); expect(getBottomInset(container)).toBe("344px");
visualViewport.height = 460; visualViewport.height = 460;
act(() => { act(() => {
@@ -168,14 +190,14 @@ describe("useChatKeyboardAvoidance", () => {
vi.advanceTimersByTime(0); vi.advanceTimersByTime(0);
}); });
expect(getBottomInset(container)).toBe("300px"); expect(getBottomInset(container)).toBe("308px");
}); });
it("removes the local inset when focus leaves", () => { it("removes the local inset when focus leaves", () => {
renderHarness(root, false); renderHarness(root, false);
renderHarness(root, true); renderHarness(root, true);
act(() => vi.advanceTimersByTime(250)); act(() => vi.advanceTimersByTime(250));
expect(getBottomInset(container)).toBe("336px"); expect(getBottomInset(container)).toBe("344px");
renderHarness(root, false); renderHarness(root, false);
+8 -2
View File
@@ -1,5 +1,6 @@
export const CHAT_KEYBOARD_VISIBLE_THRESHOLD_PX = 80; export const CHAT_KEYBOARD_VISIBLE_THRESHOLD_PX = 80;
export const CHAT_KEYBOARD_ZOOM_THRESHOLD = 1.05; 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_RATIO = 0.42;
export const CHAT_KEYBOARD_FALLBACK_MIN_PX = 260; export const CHAT_KEYBOARD_FALLBACK_MIN_PX = 260;
export const CHAT_KEYBOARD_FALLBACK_MAX_PX = 380; export const CHAT_KEYBOARD_FALLBACK_MAX_PX = 380;
@@ -67,7 +68,10 @@ export function resolveChatKeyboardInset(
return { return {
keyboardVisible: true, keyboardVisible: true,
bottomInset: Math.min(maxInset, Math.round(occludedHeight)), bottomInset: Math.min(
maxInset,
Math.round(occludedHeight + CHAT_KEYBOARD_TARGET_GAP_PX),
),
source: source:
virtualKeyboardHeight >= visualViewportHeight virtualKeyboardHeight >= visualViewportHeight
? "virtual-keyboard" ? "virtual-keyboard"
@@ -78,11 +82,13 @@ export function resolveChatKeyboardInset(
export function resolveChatKeyboardFallbackInset( export function resolveChatKeyboardFallbackInset(
layoutHeight: number, layoutHeight: number,
): number { ): number {
return Math.min( const estimatedKeyboardHeight = Math.min(
CHAT_KEYBOARD_FALLBACK_MAX_PX, CHAT_KEYBOARD_FALLBACK_MAX_PX,
Math.max( Math.max(
CHAT_KEYBOARD_FALLBACK_MIN_PX, CHAT_KEYBOARD_FALLBACK_MIN_PX,
Math.round(layoutHeight * CHAT_KEYBOARD_FALLBACK_RATIO), Math.round(layoutHeight * CHAT_KEYBOARD_FALLBACK_RATIO),
), ),
); );
return estimatedKeyboardHeight + CHAT_KEYBOARD_TARGET_GAP_PX;
} }
@@ -44,7 +44,7 @@ export function useChatKeyboardAvoidance({
}, [active]); }, [active]);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined" || !PlatformDetector.isAndroid()) { if (typeof window === "undefined" || !isKeyboardAvoidanceEnabled()) {
return; return;
} }
@@ -72,16 +72,16 @@ export function useChatKeyboardAvoidance({
const container = containerRef.current; const container = containerRef.current;
if (typeof window === "undefined" || !container) return; if (typeof window === "undefined" || !container) return;
if (!active || !PlatformDetector.isAndroid()) { const enabled = isKeyboardAvoidanceEnabled();
if (!active || !enabled) {
clearBottomInset(container); clearBottomInset(container);
if (!active) baselineRef.current = readViewportBaseline(); if (!active && enabled) baselineRef.current = readViewportBaseline();
return; return;
} }
const baseline = baselineRef.current ?? readViewportBaseline(); const baseline = baselineRef.current ?? readViewportBaseline();
const visualViewport = window.visualViewport; const visualViewport = window.visualViewport;
const virtualKeyboard = getVirtualKeyboard(); const virtualKeyboard = getVirtualKeyboard();
const fallbackAllowed = BrowserDetector.isFacebookInAppBrowser();
let frameId: number | null = null; let frameId: number | null = null;
let fallbackTimerId: number | null = null; let fallbackTimerId: number | null = null;
let lastInset = -1; let lastInset = -1;
@@ -194,8 +194,6 @@ export function useChatKeyboardAvoidance({
applyBottomInset(resolution.bottomInset, resolution.source); applyBottomInset(resolution.bottomInset, resolution.source);
return; return;
} }
if (!fallbackAllowed) return;
fallbackApplied = true; fallbackApplied = true;
applyBottomInset( applyBottomInset(
resolveChatKeyboardFallbackInset(baseline.layoutHeight), resolveChatKeyboardFallbackInset(baseline.layoutHeight),
@@ -246,3 +244,9 @@ function getVirtualKeyboard(): VirtualKeyboardLike | null {
function clearBottomInset(container: HTMLElement): void { function clearBottomInset(container: HTMLElement): void {
container.style.removeProperty(CHAT_KEYBOARD_BOTTOM_INSET_VAR); container.style.removeProperty(CHAT_KEYBOARD_BOTTOM_INSET_VAR);
} }
function isKeyboardAvoidanceEnabled(): boolean {
return (
PlatformDetector.isAndroid() && BrowserDetector.isFacebookInAppBrowser()
);
}