From ffd988bf95d21753090f64788f9bca05a3a169fe Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 16 Jul 2026 17:17:34 +0800 Subject: [PATCH] fix(chat): stabilize Facebook keyboard fallback --- .../__tests__/chat-keyboard-geometry.test.ts | 42 +++++++++++++++++-- .../use-chat-keyboard-avoidance.test.tsx | 30 +++++++------ src/app/chat/hooks/chat-keyboard-geometry.ts | 21 +++++++--- .../chat/hooks/use-chat-keyboard-avoidance.ts | 3 +- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts b/src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts index a6d1df12..cc746e55 100644 --- a/src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts +++ b/src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts @@ -9,6 +9,7 @@ import { const baseline: ChatKeyboardViewportBaseline = { layoutHeight: 800, + layoutWidth: 390, visualHeight: 760, }; @@ -82,9 +83,42 @@ describe("resolveChatKeyboardInset", () => { }); describe("resolveChatKeyboardFallbackInset", () => { - it("scales with viewport height and remains bounded", () => { - expect(resolveChatKeyboardFallbackInset(500)).toBe(276); - expect(resolveChatKeyboardFallbackInset(800)).toBe(352); - expect(resolveChatKeyboardFallbackInset(1_200)).toBe(396); + it.each([ + [{ layoutWidth: 320, layoutHeight: 568 }, 256], + [{ layoutWidth: 360, layoutHeight: 640 }, 285], + [{ 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); }); }); diff --git a/src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx b/src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx index bf8bfb8e..89603f8c 100644 --- a/src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx +++ b/src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx @@ -44,6 +44,7 @@ describe("useChatKeyboardAvoidance", () => { let visualViewport: MockVisualViewport; let virtualKeyboard: MockVirtualKeyboard; let innerHeight: number; + let innerWidth: number; beforeEach(() => { vi.useFakeTimers(); @@ -52,12 +53,17 @@ describe("useChatKeyboardAvoidance", () => { vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(true); vi.mocked(PlatformDetector.isAndroid).mockReturnValue(true); innerHeight = 800; + innerWidth = 390; visualViewport = new MockVisualViewport(); virtualKeyboard = new MockVirtualKeyboard(); Object.defineProperty(window, "innerHeight", { configurable: true, get: () => innerHeight, }); + Object.defineProperty(window, "innerWidth", { + configurable: true, + get: () => innerWidth, + }); Object.defineProperty(window, "visualViewport", { configurable: true, value: visualViewport, @@ -89,16 +95,16 @@ describe("useChatKeyboardAvoidance", () => { 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"); renderHarness(root, false); renderHarness(root, true); - act(() => vi.advanceTimersByTime(249)); + act(() => vi.advanceTimersByTime(149)); expect(getBottomInset(container)).toBe(""); act(() => vi.advanceTimersByTime(1)); - expect(getBottomInset(container)).toBe("352px"); + expect(getBottomInset(container)).toBe("316px"); expect(intervalSpy).not.toHaveBeenCalled(); }); @@ -112,7 +118,7 @@ describe("useChatKeyboardAvoidance", () => { act(() => { visualViewport.dispatchEvent(new Event("resize")); virtualKeyboard.dispatchEvent(new Event("geometrychange")); - vi.advanceTimersByTime(250); + vi.advanceTimersByTime(150); }); expect(getBottomInset(container)).toBe(""); @@ -126,7 +132,7 @@ describe("useChatKeyboardAvoidance", () => { visualViewport.height = 460; act(() => { visualViewport.dispatchEvent(new Event("resize")); - vi.advanceTimersByTime(250); + vi.advanceTimersByTime(150); }); expect(getBottomInset(container)).toBe(""); @@ -172,7 +178,7 @@ describe("useChatKeyboardAvoidance", () => { visualViewport.height = 500; act(() => { window.dispatchEvent(new Event("resize")); - vi.advanceTimersByTime(250); + vi.advanceTimersByTime(150); }); expect(getBottomInset(container)).toBe("0px"); @@ -181,23 +187,23 @@ describe("useChatKeyboardAvoidance", () => { it("allows a delayed real viewport signal to replace the fallback", () => { renderHarness(root, false); renderHarness(root, true); - act(() => vi.advanceTimersByTime(250)); - expect(getBottomInset(container)).toBe("352px"); + act(() => vi.advanceTimersByTime(150)); + expect(getBottomInset(container)).toBe("316px"); - visualViewport.height = 460; + visualViewport.height = 500; act(() => { visualViewport.dispatchEvent(new Event("resize")); vi.advanceTimersByTime(0); }); - expect(getBottomInset(container)).toBe("316px"); + expect(getBottomInset(container)).toBe("276px"); }); it("removes the local inset when focus leaves", () => { renderHarness(root, false); renderHarness(root, true); - act(() => vi.advanceTimersByTime(250)); - expect(getBottomInset(container)).toBe("352px"); + act(() => vi.advanceTimersByTime(150)); + expect(getBottomInset(container)).toBe("316px"); renderHarness(root, false); diff --git a/src/app/chat/hooks/chat-keyboard-geometry.ts b/src/app/chat/hooks/chat-keyboard-geometry.ts index ebbac261..e3dd4c3a 100644 --- a/src/app/chat/hooks/chat-keyboard-geometry.ts +++ b/src/app/chat/hooks/chat-keyboard-geometry.ts @@ -1,17 +1,24 @@ export const CHAT_KEYBOARD_VISIBLE_THRESHOLD_PX = 80; export const CHAT_KEYBOARD_ZOOM_THRESHOLD = 1.05; export const CHAT_KEYBOARD_TARGET_GAP_PX = 16; -export const CHAT_KEYBOARD_FALLBACK_RATIO = 0.42; -export const CHAT_KEYBOARD_FALLBACK_MIN_PX = 260; -export const CHAT_KEYBOARD_FALLBACK_MAX_PX = 380; +export const CHAT_KEYBOARD_FALLBACK_WIDTH_RATIO = 0.77; +export const CHAT_KEYBOARD_FALLBACK_HEIGHT_RATIO = 0.42; +export const CHAT_KEYBOARD_FALLBACK_MIN_PX = 240; +export const CHAT_KEYBOARD_FALLBACK_MAX_PX = 340; const CHAT_KEYBOARD_MAX_INSET_RATIO = 0.55; export interface ChatKeyboardViewportBaseline { layoutHeight: number; + layoutWidth: number; visualHeight: number; } +export type ChatKeyboardFallbackViewport = Pick< + ChatKeyboardViewportBaseline, + "layoutHeight" | "layoutWidth" +>; + export interface ChatKeyboardViewportSnapshot { layoutHeight: number; visualHeight: number; @@ -80,13 +87,17 @@ export function resolveChatKeyboardInset( } export function resolveChatKeyboardFallbackInset( - layoutHeight: number, + viewport: ChatKeyboardFallbackViewport, ): number { + const widthEstimate = + viewport.layoutWidth * CHAT_KEYBOARD_FALLBACK_WIDTH_RATIO; + const heightLimit = + viewport.layoutHeight * CHAT_KEYBOARD_FALLBACK_HEIGHT_RATIO; const estimatedKeyboardHeight = Math.min( CHAT_KEYBOARD_FALLBACK_MAX_PX, Math.max( CHAT_KEYBOARD_FALLBACK_MIN_PX, - Math.round(layoutHeight * CHAT_KEYBOARD_FALLBACK_RATIO), + Math.round(Math.min(widthEstimate, heightLimit)), ), ); diff --git a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts index f43a29ad..6b598142 100644 --- a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts +++ b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts @@ -196,7 +196,7 @@ export function useChatKeyboardAvoidance({ } fallbackApplied = true; applyBottomInset( - resolveChatKeyboardFallbackInset(baseline.layoutHeight), + resolveChatKeyboardFallbackInset(baseline), "fallback", ); }, CHAT_KEYBOARD_FALLBACK_DELAY_MS); @@ -221,6 +221,7 @@ export function useChatKeyboardAvoidance({ function readViewportBaseline(): ChatKeyboardViewportBaseline { return { layoutHeight: window.innerHeight, + layoutWidth: window.innerWidth, visualHeight: window.visualViewport?.height ?? window.innerHeight, }; }