fix(chat): stabilize Facebook keyboard fallback

This commit is contained in:
2026-07-16 17:17:34 +08:00
parent f8934b1412
commit ffd988bf95
4 changed files with 74 additions and 22 deletions
@@ -9,6 +9,7 @@ import {
const baseline: ChatKeyboardViewportBaseline = { const baseline: ChatKeyboardViewportBaseline = {
layoutHeight: 800, layoutHeight: 800,
layoutWidth: 390,
visualHeight: 760, visualHeight: 760,
}; };
@@ -82,9 +83,42 @@ describe("resolveChatKeyboardInset", () => {
}); });
describe("resolveChatKeyboardFallbackInset", () => { describe("resolveChatKeyboardFallbackInset", () => {
it("scales with viewport height and remains bounded", () => { it.each([
expect(resolveChatKeyboardFallbackInset(500)).toBe(276); [{ layoutWidth: 320, layoutHeight: 568 }, 256],
expect(resolveChatKeyboardFallbackInset(800)).toBe(352); [{ layoutWidth: 360, layoutHeight: 640 }, 285],
expect(resolveChatKeyboardFallbackInset(1_200)).toBe(396); [{ layoutWidth: 390, layoutHeight: 844 }, 316],
[{ layoutWidth: 430, layoutHeight: 932 }, 347],
])("fits common viewport %o", (viewport, expectedInset) => {
expect(resolveChatKeyboardFallbackInset(viewport)).toBe(expectedInset);
});
it("stays stable across tall viewports with the same width", () => {
expect(
resolveChatKeyboardFallbackInset({
layoutWidth: 390,
layoutHeight: 760,
}),
).toBe(316);
expect(
resolveChatKeyboardFallbackInset({
layoutWidth: 390,
layoutHeight: 900,
}),
).toBe(316);
});
it("keeps the keyboard estimate within its bounds", () => {
expect(
resolveChatKeyboardFallbackInset({
layoutWidth: 300,
layoutHeight: 400,
}),
).toBe(256);
expect(
resolveChatKeyboardFallbackInset({
layoutWidth: 600,
layoutHeight: 1_000,
}),
).toBe(356);
}); });
}); });
@@ -44,6 +44,7 @@ describe("useChatKeyboardAvoidance", () => {
let visualViewport: MockVisualViewport; let visualViewport: MockVisualViewport;
let virtualKeyboard: MockVirtualKeyboard; let virtualKeyboard: MockVirtualKeyboard;
let innerHeight: number; let innerHeight: number;
let innerWidth: number;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -52,12 +53,17 @@ describe("useChatKeyboardAvoidance", () => {
vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(true); vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(true);
vi.mocked(PlatformDetector.isAndroid).mockReturnValue(true); vi.mocked(PlatformDetector.isAndroid).mockReturnValue(true);
innerHeight = 800; innerHeight = 800;
innerWidth = 390;
visualViewport = new MockVisualViewport(); visualViewport = new MockVisualViewport();
virtualKeyboard = new MockVirtualKeyboard(); virtualKeyboard = new MockVirtualKeyboard();
Object.defineProperty(window, "innerHeight", { Object.defineProperty(window, "innerHeight", {
configurable: true, configurable: true,
get: () => innerHeight, get: () => innerHeight,
}); });
Object.defineProperty(window, "innerWidth", {
configurable: true,
get: () => innerWidth,
});
Object.defineProperty(window, "visualViewport", { Object.defineProperty(window, "visualViewport", {
configurable: true, configurable: true,
value: visualViewport, value: visualViewport,
@@ -89,16 +95,16 @@ describe("useChatKeyboardAvoidance", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("uses a one-shot Facebook fallback after 250ms without polling", () => { it("uses a one-shot Facebook fallback after 150ms without polling", () => {
const intervalSpy = vi.spyOn(window, "setInterval"); const intervalSpy = vi.spyOn(window, "setInterval");
renderHarness(root, false); renderHarness(root, false);
renderHarness(root, true); renderHarness(root, true);
act(() => vi.advanceTimersByTime(249)); act(() => vi.advanceTimersByTime(149));
expect(getBottomInset(container)).toBe(""); expect(getBottomInset(container)).toBe("");
act(() => vi.advanceTimersByTime(1)); act(() => vi.advanceTimersByTime(1));
expect(getBottomInset(container)).toBe("352px"); expect(getBottomInset(container)).toBe("316px");
expect(intervalSpy).not.toHaveBeenCalled(); expect(intervalSpy).not.toHaveBeenCalled();
}); });
@@ -112,7 +118,7 @@ describe("useChatKeyboardAvoidance", () => {
act(() => { act(() => {
visualViewport.dispatchEvent(new Event("resize")); visualViewport.dispatchEvent(new Event("resize"));
virtualKeyboard.dispatchEvent(new Event("geometrychange")); virtualKeyboard.dispatchEvent(new Event("geometrychange"));
vi.advanceTimersByTime(250); vi.advanceTimersByTime(150);
}); });
expect(getBottomInset(container)).toBe(""); expect(getBottomInset(container)).toBe("");
@@ -126,7 +132,7 @@ describe("useChatKeyboardAvoidance", () => {
visualViewport.height = 460; visualViewport.height = 460;
act(() => { act(() => {
visualViewport.dispatchEvent(new Event("resize")); visualViewport.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(250); vi.advanceTimersByTime(150);
}); });
expect(getBottomInset(container)).toBe(""); expect(getBottomInset(container)).toBe("");
@@ -172,7 +178,7 @@ describe("useChatKeyboardAvoidance", () => {
visualViewport.height = 500; visualViewport.height = 500;
act(() => { act(() => {
window.dispatchEvent(new Event("resize")); window.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(250); vi.advanceTimersByTime(150);
}); });
expect(getBottomInset(container)).toBe("0px"); expect(getBottomInset(container)).toBe("0px");
@@ -181,23 +187,23 @@ describe("useChatKeyboardAvoidance", () => {
it("allows a delayed real viewport signal to replace the fallback", () => { it("allows a delayed real viewport signal to replace the fallback", () => {
renderHarness(root, false); renderHarness(root, false);
renderHarness(root, true); renderHarness(root, true);
act(() => vi.advanceTimersByTime(250)); act(() => vi.advanceTimersByTime(150));
expect(getBottomInset(container)).toBe("352px"); expect(getBottomInset(container)).toBe("316px");
visualViewport.height = 460; visualViewport.height = 500;
act(() => { act(() => {
visualViewport.dispatchEvent(new Event("resize")); visualViewport.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(0); vi.advanceTimersByTime(0);
}); });
expect(getBottomInset(container)).toBe("316px"); expect(getBottomInset(container)).toBe("276px");
}); });
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(150));
expect(getBottomInset(container)).toBe("352px"); expect(getBottomInset(container)).toBe("316px");
renderHarness(root, false); renderHarness(root, false);
+16 -5
View File
@@ -1,17 +1,24 @@
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 = 16; export const CHAT_KEYBOARD_TARGET_GAP_PX = 16;
export const CHAT_KEYBOARD_FALLBACK_RATIO = 0.42; export const CHAT_KEYBOARD_FALLBACK_WIDTH_RATIO = 0.77;
export const CHAT_KEYBOARD_FALLBACK_MIN_PX = 260; export const CHAT_KEYBOARD_FALLBACK_HEIGHT_RATIO = 0.42;
export const CHAT_KEYBOARD_FALLBACK_MAX_PX = 380; export const CHAT_KEYBOARD_FALLBACK_MIN_PX = 240;
export const CHAT_KEYBOARD_FALLBACK_MAX_PX = 340;
const CHAT_KEYBOARD_MAX_INSET_RATIO = 0.55; const CHAT_KEYBOARD_MAX_INSET_RATIO = 0.55;
export interface ChatKeyboardViewportBaseline { export interface ChatKeyboardViewportBaseline {
layoutHeight: number; layoutHeight: number;
layoutWidth: number;
visualHeight: number; visualHeight: number;
} }
export type ChatKeyboardFallbackViewport = Pick<
ChatKeyboardViewportBaseline,
"layoutHeight" | "layoutWidth"
>;
export interface ChatKeyboardViewportSnapshot { export interface ChatKeyboardViewportSnapshot {
layoutHeight: number; layoutHeight: number;
visualHeight: number; visualHeight: number;
@@ -80,13 +87,17 @@ export function resolveChatKeyboardInset(
} }
export function resolveChatKeyboardFallbackInset( export function resolveChatKeyboardFallbackInset(
layoutHeight: number, viewport: ChatKeyboardFallbackViewport,
): number { ): number {
const widthEstimate =
viewport.layoutWidth * CHAT_KEYBOARD_FALLBACK_WIDTH_RATIO;
const heightLimit =
viewport.layoutHeight * CHAT_KEYBOARD_FALLBACK_HEIGHT_RATIO;
const estimatedKeyboardHeight = 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(Math.min(widthEstimate, heightLimit)),
), ),
); );
@@ -196,7 +196,7 @@ export function useChatKeyboardAvoidance({
} }
fallbackApplied = true; fallbackApplied = true;
applyBottomInset( applyBottomInset(
resolveChatKeyboardFallbackInset(baseline.layoutHeight), resolveChatKeyboardFallbackInset(baseline),
"fallback", "fallback",
); );
}, CHAT_KEYBOARD_FALLBACK_DELAY_MS); }, CHAT_KEYBOARD_FALLBACK_DELAY_MS);
@@ -221,6 +221,7 @@ export function useChatKeyboardAvoidance({
function readViewportBaseline(): ChatKeyboardViewportBaseline { function readViewportBaseline(): ChatKeyboardViewportBaseline {
return { return {
layoutHeight: window.innerHeight, layoutHeight: window.innerHeight,
layoutWidth: window.innerWidth,
visualHeight: window.visualViewport?.height ?? window.innerHeight, visualHeight: window.visualViewport?.height ?? window.innerHeight,
}; };
} }