From 54b903162210708bbd0f146546cf9737e8bf804b Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 16 Jul 2026 16:28:10 +0800 Subject: [PATCH] fix(chat): restore Facebook keyboard avoidance --- .../chat/components/chat-input-bar.module.css | 7 +- src/app/chat/components/chat-input-bar.tsx | 7 +- .../__tests__/chat-keyboard-geometry.test.ts | 90 +++++++ .../use-chat-keyboard-avoidance.test.tsx | 196 ++++++++++++++ .../use-chat-keyboard-diagnostics.test.tsx | 2 + ...rtual-keyboard-overlay-experiment.test.tsx | 43 --- src/app/chat/hooks/chat-keyboard-geometry.ts | 88 +++++++ .../chat/hooks/use-chat-keyboard-avoidance.ts | 248 ++++++++++++++++++ .../hooks/use-chat-keyboard-diagnostics.ts | 9 +- ...hat-virtual-keyboard-overlay-experiment.ts | 73 ------ 10 files changed, 642 insertions(+), 121 deletions(-) create mode 100644 src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts create mode 100644 src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx delete mode 100644 src/app/chat/hooks/__tests__/use-chat-virtual-keyboard-overlay-experiment.test.tsx create mode 100644 src/app/chat/hooks/chat-keyboard-geometry.ts create mode 100644 src/app/chat/hooks/use-chat-keyboard-avoidance.ts delete mode 100644 src/app/chat/hooks/use-chat-virtual-keyboard-overlay-experiment.ts diff --git a/src/app/chat/components/chat-input-bar.module.css b/src/app/chat/components/chat-input-bar.module.css index ac57c7cc..92806a97 100644 --- a/src/app/chat/components/chat-input-bar.module.css +++ b/src/app/chat/components/chat-input-bar.module.css @@ -1,11 +1,14 @@ /* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */ +/* Keyboard inset is a total bottom inset, not an addition to normal spacing. */ + .bar { flex: 0 0 auto; padding: 0 calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px)) - calc( - var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px) + max( + calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)), + var(--chat-keyboard-bottom-inset, 0px) ) calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px)); background: transparent; diff --git a/src/app/chat/components/chat-input-bar.tsx b/src/app/chat/components/chat-input-bar.tsx index bdd4ac7a..3bd85259 100644 --- a/src/app/chat/components/chat-input-bar.tsx +++ b/src/app/chat/components/chat-input-bar.tsx @@ -4,8 +4,8 @@ import { useRef, useState } from "react"; import { useChatDispatch } from "@/stores/chat/chat-context"; import { Logger } from "@/utils/logger"; +import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance"; import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics"; -import { useChatVirtualKeyboardOverlayExperiment } from "../hooks/use-chat-virtual-keyboard-overlay-experiment"; import { ChatInputTextField } from "./chat-input-text-field"; import { ChatSendButton } from "./chat-send-button"; import styles from "./chat-input-bar.module.css"; @@ -27,7 +27,10 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) { const hasContent = input.trim().length > 0; - useChatVirtualKeyboardOverlayExperiment(); + useChatKeyboardAvoidance({ + active: isFocused, + containerRef: barRef, + }); useChatKeyboardDiagnostics({ containerRef: barRef }); const handleInputChange = (value: string) => { diff --git a/src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts b/src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts new file mode 100644 index 00000000..bb8cf21c --- /dev/null +++ b/src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveChatKeyboardFallbackInset, + resolveChatKeyboardInset, + type ChatKeyboardViewportBaseline, + type ChatKeyboardViewportSnapshot, +} from "../chat-keyboard-geometry"; + +const baseline: ChatKeyboardViewportBaseline = { + layoutHeight: 800, + visualHeight: 760, +}; + +function makeSnapshot( + overrides: Partial = {}, +): ChatKeyboardViewportSnapshot { + return { + layoutHeight: 800, + visualHeight: 760, + visualScale: 1, + virtualKeyboardHeight: 0, + ...overrides, + }; +} + +describe("resolveChatKeyboardInset", () => { + it("does not add an inset when the content viewport already resized", () => { + expect( + resolveChatKeyboardInset( + baseline, + makeSnapshot({ layoutHeight: 500, visualHeight: 500 }), + ), + ).toEqual({ + keyboardVisible: true, + bottomInset: 0, + source: "content-resize", + }); + }); + + it("uses the visual viewport occlusion without an extra gap", () => { + expect( + resolveChatKeyboardInset( + baseline, + makeSnapshot({ visualHeight: 460 }), + ), + ).toEqual({ + keyboardVisible: true, + bottomInset: 300, + source: "visual-viewport", + }); + }); + + it("prefers Virtual Keyboard geometry when available", () => { + expect( + resolveChatKeyboardInset( + baseline, + makeSnapshot({ + visualHeight: 520, + virtualKeyboardHeight: 320, + }), + ), + ).toEqual({ + keyboardVisible: true, + bottomInset: 320, + source: "virtual-keyboard", + }); + }); + + it("ignores visual viewport changes caused by page zoom", () => { + expect( + resolveChatKeyboardInset( + baseline, + makeSnapshot({ visualHeight: 460, visualScale: 1.2 }), + ), + ).toEqual({ + keyboardVisible: false, + bottomInset: 0, + source: "none", + }); + }); +}); + +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); + }); +}); 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 new file mode 100644 index 00000000..6bcb8d2b --- /dev/null +++ b/src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx @@ -0,0 +1,196 @@ +/* @vitest-environment jsdom */ + +import { act, useRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BrowserDetector } from "@/utils/browser-detect"; + +import { useChatKeyboardAvoidance } from "../use-chat-keyboard-avoidance"; + +vi.mock("@/utils/browser-detect", () => ({ + BrowserDetector: { + isFacebookInAppBrowser: vi.fn(() => true), + }, +})); + +vi.mock("@/utils/platform-detect", () => ({ + PlatformDetector: { + isAndroid: vi.fn(() => true), + }, +})); + +class MockVisualViewport extends EventTarget { + height = 760; + offsetTop = 0; + scale = 1; + width = 390; +} + +class MockVirtualKeyboard extends EventTarget { + boundingRect = { height: 0 }; +} + +function Harness({ active }: { active: boolean }) { + const containerRef = useRef(null); + useChatKeyboardAvoidance({ active, containerRef }); + return
; +} + +describe("useChatKeyboardAvoidance", () => { + let container: HTMLDivElement; + let root: Root; + let visualViewport: MockVisualViewport; + let virtualKeyboard: MockVirtualKeyboard; + let innerHeight: number; + + beforeEach(() => { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(true); + innerHeight = 800; + visualViewport = new MockVisualViewport(); + virtualKeyboard = new MockVirtualKeyboard(); + Object.defineProperty(window, "innerHeight", { + configurable: true, + get: () => innerHeight, + }); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + Object.defineProperty(navigator, "virtualKeyboard", { + configurable: true, + value: virtualKeyboard, + }); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => + window.setTimeout(() => callback(0), 0), + ), + ); + vi.stubGlobal( + "cancelAnimationFrame", + vi.fn((id: number) => window.clearTimeout(id)), + ); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("uses a one-shot Facebook fallback after 250ms without polling", () => { + const intervalSpy = vi.spyOn(window, "setInterval"); + renderHarness(root, false); + renderHarness(root, true); + + act(() => vi.advanceTimersByTime(249)); + expect(getBottomInset(container)).toBe(""); + + act(() => vi.advanceTimersByTime(1)); + expect(getBottomInset(container)).toBe("336px"); + expect(intervalSpy).not.toHaveBeenCalled(); + }); + + it("does not use fallback outside Facebook's in-app browser", () => { + vi.mocked(BrowserDetector.isFacebookInAppBrowser).mockReturnValue(false); + renderHarness(root, false); + renderHarness(root, true); + + act(() => vi.advanceTimersByTime(250)); + + expect(getBottomInset(container)).toBe(""); + }); + + it("uses Visual Viewport geometry and clears after keyboard dismissal", () => { + renderHarness(root, false); + renderHarness(root, true); + + visualViewport.height = 460; + act(() => { + visualViewport.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(0); + }); + expect(getBottomInset(container)).toBe("300px"); + + visualViewport.height = 760; + act(() => { + visualViewport.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(0); + }); + expect(getBottomInset(container)).toBe("0px"); + }); + + it("uses Virtual Keyboard geometry without an extra gap", () => { + renderHarness(root, false); + renderHarness(root, true); + + virtualKeyboard.boundingRect.height = 320; + act(() => { + virtualKeyboard.dispatchEvent(new Event("geometrychange")); + vi.advanceTimersByTime(0); + }); + + expect(getBottomInset(container)).toBe("320px"); + }); + + it("does not add fallback when the layout viewport resized", () => { + renderHarness(root, false); + renderHarness(root, true); + + innerHeight = 500; + visualViewport.height = 500; + act(() => { + window.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(250); + }); + + expect(getBottomInset(container)).toBe("0px"); + }); + + 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("336px"); + + visualViewport.height = 460; + act(() => { + visualViewport.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(0); + }); + + expect(getBottomInset(container)).toBe("300px"); + }); + + it("removes the local inset when focus leaves", () => { + renderHarness(root, false); + renderHarness(root, true); + act(() => vi.advanceTimersByTime(250)); + expect(getBottomInset(container)).toBe("336px"); + + renderHarness(root, false); + + expect(getBottomInset(container)).toBe(""); + }); +}); + +function renderHarness(root: Root, active: boolean): void { + act(() => root.render()); +} + +function getBottomInset(container: HTMLElement): string { + const input = container.querySelector( + '[data-testid="chat-input"]', + ); + if (!input) throw new Error("Chat input test node was not rendered"); + return input.style.getPropertyValue("--chat-keyboard-bottom-inset"); +} diff --git a/src/app/chat/hooks/__tests__/use-chat-keyboard-diagnostics.test.tsx b/src/app/chat/hooks/__tests__/use-chat-keyboard-diagnostics.test.tsx index 8083de47..9963376f 100644 --- a/src/app/chat/hooks/__tests__/use-chat-keyboard-diagnostics.test.tsx +++ b/src/app/chat/hooks/__tests__/use-chat-keyboard-diagnostics.test.tsx @@ -84,6 +84,7 @@ describe("chat keyboard diagnostics", () => { const input = document.createElement("textarea"); document.documentElement.style.setProperty("--app-viewport-height", "800px"); document.documentElement.style.setProperty("--app-visible-height", "460px"); + input.style.setProperty("--chat-keyboard-bottom-inset", "300px"); document.body.append(input); input.focus(); const inputRef = { current: input } as RefObject; @@ -103,6 +104,7 @@ describe("chat keyboard diagnostics", () => { activeFocused: true, inputBarTop: 0, inputBarBottom: 0, + keyboardBottomInset: "300px", appViewportHeight: "800px", appVisibleHeight: "460px", platform: { name: "android", deviceModel: "PLB110" }, diff --git a/src/app/chat/hooks/__tests__/use-chat-virtual-keyboard-overlay-experiment.test.tsx b/src/app/chat/hooks/__tests__/use-chat-virtual-keyboard-overlay-experiment.test.tsx deleted file mode 100644 index 2caaa8b3..00000000 --- a/src/app/chat/hooks/__tests__/use-chat-virtual-keyboard-overlay-experiment.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* @vitest-environment jsdom */ - -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { - enableChatVirtualKeyboardOverlayExperiment, - isChatVirtualKeyboardOverlayExperimentEnabled, -} from "../use-chat-virtual-keyboard-overlay-experiment"; - -describe("Virtual Keyboard Overlay experiment", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("enables only for the explicit experiment parameter", () => { - expect( - isChatVirtualKeyboardOverlayExperimentEnabled( - "?keyboard_debug=1&virtual_keyboard_overlay=1", - ), - ).toBe(true); - expect( - isChatVirtualKeyboardOverlayExperimentEnabled( - "?keyboard_debug=1&virtual_keyboard_overlay=0", - ), - ).toBe(false); - expect(isChatVirtualKeyboardOverlayExperimentEnabled("")).toBe(false); - }); - - it("enables overlay content and restores the previous value", () => { - const virtualKeyboard = { overlaysContent: false }; - Object.defineProperty(navigator, "virtualKeyboard", { - configurable: true, - value: virtualKeyboard, - }); - vi.spyOn(window.console, "info").mockImplementation(() => {}); - - const cleanup = enableChatVirtualKeyboardOverlayExperiment(); - - expect(virtualKeyboard.overlaysContent).toBe(true); - cleanup(); - expect(virtualKeyboard.overlaysContent).toBe(false); - }); -}); diff --git a/src/app/chat/hooks/chat-keyboard-geometry.ts b/src/app/chat/hooks/chat-keyboard-geometry.ts new file mode 100644 index 00000000..13aacd71 --- /dev/null +++ b/src/app/chat/hooks/chat-keyboard-geometry.ts @@ -0,0 +1,88 @@ +export const CHAT_KEYBOARD_VISIBLE_THRESHOLD_PX = 80; +export const CHAT_KEYBOARD_ZOOM_THRESHOLD = 1.05; +export const CHAT_KEYBOARD_FALLBACK_RATIO = 0.42; +export const CHAT_KEYBOARD_FALLBACK_MIN_PX = 260; +export const CHAT_KEYBOARD_FALLBACK_MAX_PX = 380; + +const CHAT_KEYBOARD_MAX_INSET_RATIO = 0.55; + +export interface ChatKeyboardViewportBaseline { + layoutHeight: number; + visualHeight: number; +} + +export interface ChatKeyboardViewportSnapshot { + layoutHeight: number; + visualHeight: number; + visualScale: number; + virtualKeyboardHeight: number; +} + +export type ChatKeyboardInsetSource = + | "content-resize" + | "visual-viewport" + | "virtual-keyboard" + | "none"; + +export interface ChatKeyboardInsetResolution { + keyboardVisible: boolean; + bottomInset: number; + source: ChatKeyboardInsetSource; +} + +export function resolveChatKeyboardInset( + baseline: ChatKeyboardViewportBaseline, + snapshot: ChatKeyboardViewportSnapshot, +): ChatKeyboardInsetResolution { + const contentResize = baseline.layoutHeight - snapshot.layoutHeight; + if (contentResize > CHAT_KEYBOARD_VISIBLE_THRESHOLD_PX) { + return { + keyboardVisible: true, + bottomInset: 0, + source: "content-resize", + }; + } + + const virtualKeyboardHeight = Math.max(0, snapshot.virtualKeyboardHeight); + const visualViewportHeight = + snapshot.visualScale <= CHAT_KEYBOARD_ZOOM_THRESHOLD + ? Math.max(0, baseline.visualHeight - snapshot.visualHeight) + : 0; + const occludedHeight = Math.max( + virtualKeyboardHeight, + visualViewportHeight, + ); + + if (occludedHeight <= CHAT_KEYBOARD_VISIBLE_THRESHOLD_PX) { + return { + keyboardVisible: false, + bottomInset: 0, + source: "none", + }; + } + + const maxInset = Math.round( + baseline.layoutHeight * CHAT_KEYBOARD_MAX_INSET_RATIO, + ); + + return { + keyboardVisible: true, + bottomInset: Math.min(maxInset, Math.round(occludedHeight)), + source: + virtualKeyboardHeight >= visualViewportHeight + ? "virtual-keyboard" + : "visual-viewport", + }; +} + +export function resolveChatKeyboardFallbackInset( + layoutHeight: number, +): number { + return Math.min( + CHAT_KEYBOARD_FALLBACK_MAX_PX, + Math.max( + CHAT_KEYBOARD_FALLBACK_MIN_PX, + Math.round(layoutHeight * CHAT_KEYBOARD_FALLBACK_RATIO), + ), + ); +} diff --git a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts new file mode 100644 index 00000000..59b897f3 --- /dev/null +++ b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts @@ -0,0 +1,248 @@ +"use client"; + +import { useEffect, useRef, type RefObject } from "react"; + +import { BrowserDetector } from "@/utils/browser-detect"; +import { Logger } from "@/utils/logger"; +import { PlatformDetector } from "@/utils/platform-detect"; + +import { + resolveChatKeyboardFallbackInset, + resolveChatKeyboardInset, + type ChatKeyboardInsetResolution, + type ChatKeyboardViewportBaseline, + type ChatKeyboardViewportSnapshot, +} from "./chat-keyboard-geometry"; + +const CHAT_KEYBOARD_BOTTOM_INSET_VAR = "--chat-keyboard-bottom-inset"; +const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 250; + +const log = new Logger("UseChatKeyboardAvoidance"); + +interface VirtualKeyboardLike extends EventTarget { + readonly boundingRect?: Pick; +} + +interface NavigatorWithVirtualKeyboard extends Navigator { + readonly virtualKeyboard?: VirtualKeyboardLike; +} + +export interface UseChatKeyboardAvoidanceOptions { + active: boolean; + containerRef: RefObject; +} + +export function useChatKeyboardAvoidance({ + active, + containerRef, +}: UseChatKeyboardAvoidanceOptions): void { + const activeRef = useRef(active); + const baselineRef = useRef(null); + + useEffect(() => { + activeRef.current = active; + }, [active]); + + useEffect(() => { + if (typeof window === "undefined" || !PlatformDetector.isAndroid()) { + return; + } + + const updateBaseline = () => { + if (activeRef.current) return; + baselineRef.current = readViewportBaseline(); + }; + const visualViewport = window.visualViewport; + + updateBaseline(); + window.addEventListener("resize", updateBaseline); + window.addEventListener("orientationchange", updateBaseline); + visualViewport?.addEventListener("resize", updateBaseline); + visualViewport?.addEventListener("scroll", updateBaseline); + + return () => { + window.removeEventListener("resize", updateBaseline); + window.removeEventListener("orientationchange", updateBaseline); + visualViewport?.removeEventListener("resize", updateBaseline); + visualViewport?.removeEventListener("scroll", updateBaseline); + }; + }, []); + + useEffect(() => { + const container = containerRef.current; + if (typeof window === "undefined" || !container) return; + + if (!active || !PlatformDetector.isAndroid()) { + clearBottomInset(container); + if (!active) 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; + let pendingCanDismiss = false; + let pendingReason = "initial"; + let keyboardObserved = false; + let fallbackApplied = false; + let sessionDismissed = false; + + const applyBottomInset = ( + bottomInset: number, + source: ChatKeyboardInsetResolution["source"] | "fallback" | "reset", + ) => { + const nextInset = Math.max(0, Math.round(bottomInset)); + if (nextInset === lastInset) return; + + lastInset = nextInset; + container.style.setProperty( + CHAT_KEYBOARD_BOTTOM_INSET_VAR, + `${nextInset}px`, + ); + log.debug("[chat-keyboard] apply", { + source, + bottomInset: nextInset, + }); + }; + + const clearFallbackTimer = () => { + if (fallbackTimerId == null) return; + window.clearTimeout(fallbackTimerId); + fallbackTimerId = null; + }; + + const measure = (reason: string, canDismiss: boolean) => { + if (sessionDismissed) return; + + const resolution = resolveChatKeyboardInset( + baseline, + readViewportSnapshot(), + ); + + if (resolution.keyboardVisible) { + keyboardObserved = true; + clearFallbackTimer(); + applyBottomInset(resolution.bottomInset, resolution.source); + return; + } + + if ((keyboardObserved || fallbackApplied) && canDismiss) { + sessionDismissed = true; + clearFallbackTimer(); + applyBottomInset(0, "reset"); + log.debug("[chat-keyboard] dismissed", { reason }); + } + }; + + const scheduleMeasure = (reason: string, canDismiss: boolean) => { + pendingReason = reason; + pendingCanDismiss ||= canDismiss; + if (frameId != null) return; + + frameId = window.requestAnimationFrame(() => { + frameId = null; + const nextReason = pendingReason; + const nextCanDismiss = pendingCanDismiss; + pendingCanDismiss = false; + measure(nextReason, nextCanDismiss); + }); + }; + + const handleVisualResize = () => scheduleMeasure("visual-resize", true); + const handleVisualScroll = () => scheduleMeasure("visual-scroll", false); + const handleWindowResize = () => scheduleMeasure("window-resize", true); + const handleVirtualKeyboardChange = () => + scheduleMeasure("virtual-keyboard", true); + const resetSession = (reason: string) => { + sessionDismissed = true; + clearFallbackTimer(); + applyBottomInset(0, "reset"); + log.debug("[chat-keyboard] reset", { reason }); + }; + const handleOrientationChange = () => resetSession("orientationchange"); + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + resetSession("visibilitychange"); + } + }; + + visualViewport?.addEventListener("resize", handleVisualResize); + visualViewport?.addEventListener("scroll", handleVisualScroll); + virtualKeyboard?.addEventListener( + "geometrychange", + handleVirtualKeyboardChange, + ); + window.addEventListener("resize", handleWindowResize); + window.addEventListener("orientationchange", handleOrientationChange); + document.addEventListener("visibilitychange", handleVisibilityChange); + + scheduleMeasure("focus", false); + fallbackTimerId = window.setTimeout(() => { + fallbackTimerId = null; + if (!activeRef.current || sessionDismissed) return; + + const resolution = resolveChatKeyboardInset( + baseline, + readViewportSnapshot(), + ); + if (resolution.keyboardVisible) { + keyboardObserved = true; + applyBottomInset(resolution.bottomInset, resolution.source); + return; + } + if (!fallbackAllowed) return; + + fallbackApplied = true; + applyBottomInset( + resolveChatKeyboardFallbackInset(baseline.layoutHeight), + "fallback", + ); + }, CHAT_KEYBOARD_FALLBACK_DELAY_MS); + + return () => { + if (frameId != null) window.cancelAnimationFrame(frameId); + clearFallbackTimer(); + visualViewport?.removeEventListener("resize", handleVisualResize); + visualViewport?.removeEventListener("scroll", handleVisualScroll); + virtualKeyboard?.removeEventListener( + "geometrychange", + handleVirtualKeyboardChange, + ); + window.removeEventListener("resize", handleWindowResize); + window.removeEventListener("orientationchange", handleOrientationChange); + document.removeEventListener("visibilitychange", handleVisibilityChange); + clearBottomInset(container); + }; + }, [active, containerRef]); +} + +function readViewportBaseline(): ChatKeyboardViewportBaseline { + return { + layoutHeight: window.innerHeight, + visualHeight: window.visualViewport?.height ?? window.innerHeight, + }; +} + +function readViewportSnapshot(): ChatKeyboardViewportSnapshot { + const visualViewport = window.visualViewport; + return { + layoutHeight: window.innerHeight, + visualHeight: visualViewport?.height ?? window.innerHeight, + visualScale: visualViewport?.scale ?? 1, + virtualKeyboardHeight: getVirtualKeyboard()?.boundingRect?.height ?? 0, + }; +} + +function getVirtualKeyboard(): VirtualKeyboardLike | null { + return ( + (navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null + ); +} + +function clearBottomInset(container: HTMLElement): void { + container.style.removeProperty(CHAT_KEYBOARD_BOTTOM_INSET_VAR); +} diff --git a/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts b/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts index b7bc6c0e..f46ddc4f 100644 --- a/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts +++ b/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts @@ -44,6 +44,8 @@ export interface ChatKeyboardDebugSnapshot { activeFocused: boolean; inputBarTop: number | null; inputBarBottom: number | null; + inputBarPaddingBottom: string | null; + keyboardBottomInset: string | null; appViewportHeight: string | null; appVisibleHeight: string | null; appVisibleOffsetTop: string | null; @@ -157,7 +159,9 @@ function createChatKeyboardDebugSnapshot( const platform = PlatformDetector.getPlatformInfo(); const browser = BrowserDetector.getBrowserInfo(); const rootStyle = document.documentElement.style; - const inputBarRect = containerRef.current?.getBoundingClientRect() ?? null; + const inputBar = containerRef.current; + const inputBarRect = inputBar?.getBoundingClientRect() ?? null; + const inputBarStyle = inputBar ? window.getComputedStyle(inputBar) : null; return { event, @@ -187,6 +191,9 @@ function createChatKeyboardDebugSnapshot( activeFocused: activeElement !== document.body, inputBarTop: inputBarRect?.top ?? null, inputBarBottom: inputBarRect?.bottom ?? null, + inputBarPaddingBottom: inputBarStyle?.paddingBottom ?? null, + keyboardBottomInset: + inputBar?.style.getPropertyValue("--chat-keyboard-bottom-inset") || null, appViewportHeight: rootStyle.getPropertyValue("--app-viewport-height") || null, appVisibleHeight: diff --git a/src/app/chat/hooks/use-chat-virtual-keyboard-overlay-experiment.ts b/src/app/chat/hooks/use-chat-virtual-keyboard-overlay-experiment.ts deleted file mode 100644 index eb478735..00000000 --- a/src/app/chat/hooks/use-chat-virtual-keyboard-overlay-experiment.ts +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -const CHAT_VIRTUAL_KEYBOARD_OVERLAY_PARAM = "virtual_keyboard_overlay"; - -interface VirtualKeyboardOverlayLike { - overlaysContent: boolean; -} - -interface NavigatorWithVirtualKeyboard extends Navigator { - readonly virtualKeyboard?: VirtualKeyboardOverlayLike; -} - -export function useChatVirtualKeyboardOverlayExperiment(): void { - useEffect(() => { - if (!isChatVirtualKeyboardOverlayExperimentEnabled(window.location.search)) { - return; - } - return enableChatVirtualKeyboardOverlayExperiment(); - }, []); -} - -export function isChatVirtualKeyboardOverlayExperimentEnabled( - search: string, -): boolean { - return ( - new URLSearchParams(search).get(CHAT_VIRTUAL_KEYBOARD_OVERLAY_PARAM) === - "1" - ); -} - -export function enableChatVirtualKeyboardOverlayExperiment(): () => void { - const virtualKeyboard = getVirtualKeyboard(); - if (!virtualKeyboard) { - window.console.info( - "[keyboard-debug] Virtual Keyboard Overlay API is unavailable", - ); - return () => {}; - } - - const previousValue = virtualKeyboard.overlaysContent; - try { - virtualKeyboard.overlaysContent = true; - window.console.info("[keyboard-debug] Virtual Keyboard Overlay enabled", { - previousValue, - currentValue: virtualKeyboard.overlaysContent, - }); - } catch (error) { - window.console.info( - "[keyboard-debug] Virtual Keyboard Overlay failed", - error, - ); - return () => {}; - } - - return () => { - try { - virtualKeyboard.overlaysContent = previousValue; - } catch { - // The WebView may invalidate the API object while the route is closing. - } - }; -} - -function getVirtualKeyboard(): VirtualKeyboardOverlayLike | null { - const virtualKeyboard = ( - navigator as NavigatorWithVirtualKeyboard - ).virtualKeyboard; - return virtualKeyboard && typeof virtualKeyboard.overlaysContent === "boolean" - ? virtualKeyboard - : null; -}