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 bb8cf21c..4c21adba 100644 --- a/src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts +++ b/src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts @@ -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); }); }); 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 6bcb8d2b..ad8f2951 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 @@ -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); diff --git a/src/app/chat/hooks/chat-keyboard-geometry.ts b/src/app/chat/hooks/chat-keyboard-geometry.ts index 13aacd71..2521d0bf 100644 --- a/src/app/chat/hooks/chat-keyboard-geometry.ts +++ b/src/app/chat/hooks/chat-keyboard-geometry.ts @@ -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; } diff --git a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts index 59b897f3..f88a25d2 100644 --- a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts +++ b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts @@ -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() + ); +}