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 new file mode 100644 index 00000000..388ca2fe --- /dev/null +++ b/src/app/chat/hooks/__tests__/use-chat-keyboard-diagnostics.test.tsx @@ -0,0 +1,119 @@ +/* @vitest-environment jsdom */ + +import { type RefObject } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + installChatKeyboardDiagnostics, + isChatKeyboardDiagnosticsEnabled, +} from "../use-chat-keyboard-diagnostics"; + +vi.mock("@/utils/browser-detect", () => ({ + BrowserDetector: { + getBrowserInfo: vi.fn(() => ({ + name: "Chrome WebView", + version: "126.0.0.0", + inAppBrowserName: "facebook", + })), + }, +})); + +vi.mock("@/utils/platform-detect", () => ({ + PlatformDetector: { + getPlatformInfo: vi.fn(() => ({ + platform: "android", + osName: "Android", + osVersion: "15", + deviceVendor: "OnePlus", + deviceModel: "PLB110", + })), + }, +})); + +class MockVisualViewport extends EventTarget { + height = 460; + offsetLeft = 0; + offsetTop = 0; + scale = 1; + width = 390; +} + +class MockVirtualKeyboard extends EventTarget { + boundingRect = { + x: 0, + y: 460, + width: 390, + height: 300, + }; +} + +describe("chat keyboard diagnostics", () => { + let consoleInfo: ReturnType; + let visualViewport: MockVisualViewport; + let virtualKeyboard: MockVirtualKeyboard; + + beforeEach(() => { + visualViewport = new MockVisualViewport(); + virtualKeyboard = new MockVirtualKeyboard(); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + Object.defineProperty(navigator, "virtualKeyboard", { + configurable: true, + value: virtualKeyboard, + }); + consoleInfo = vi.spyOn(window.console, "info").mockImplementation(() => {}); + }); + + afterEach(() => { + window.__keyboardDebugCleanup?.(); + vi.restoreAllMocks(); + }); + + it("enables diagnostics only for the explicit query parameter", () => { + expect(isChatKeyboardDiagnosticsEnabled("?keyboard_debug=1")).toBe(true); + expect(isChatKeyboardDiagnosticsEnabled("?keyboard_debug=0")).toBe(false); + expect(isChatKeyboardDiagnosticsEnabled("")).toBe(false); + }); + + it("records viewport events and exposes manual snapshot helpers", () => { + const input = document.createElement("textarea"); + input.style.setProperty("--chat-input-lift", "314px"); + document.body.append(input); + input.focus(); + const inputRef = { current: input } as RefObject; + + const cleanup = installChatKeyboardDiagnostics(inputRef); + + expect(window.__keyboardDebugSnapshot).toBeTypeOf("function"); + expect(window.__keyboardDebugCleanup).toBe(cleanup); + expect(window.__keyboardDebugSnapshot?.()).toMatchObject({ + event: "manual", + visualViewportSupported: true, + visualHeight: 460, + virtualKeyboardSupported: true, + virtualKeyboardRect: { height: 300 }, + activeElement: "TEXTAREA", + activeFocused: true, + inputLift: "314px", + platform: { name: "android", deviceModel: "PLB110" }, + browser: { inAppBrowserName: "facebook" }, + }); + + visualViewport.dispatchEvent(new Event("resize")); + expect(consoleInfo).toHaveBeenLastCalledWith( + "[keyboard-debug]", + expect.objectContaining({ event: "visualViewport.resize" }), + ); + + const callCount = consoleInfo.mock.calls.length; + cleanup(); + visualViewport.dispatchEvent(new Event("resize")); + expect(consoleInfo).toHaveBeenCalledTimes(callCount + 1); + expect(window.__keyboardDebugSnapshot).toBeUndefined(); + expect(window.__keyboardDebugCleanup).toBeUndefined(); + + input.remove(); + }); +}); diff --git a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts index 7e0cbb62..466f96cf 100644 --- a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts +++ b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts @@ -13,6 +13,7 @@ import { type ChatKeyboardViewportBaseline, type ChatKeyboardViewportSnapshot, } from "./chat-keyboard-geometry"; +import { useChatKeyboardDiagnostics } from "./use-chat-keyboard-diagnostics"; const CHAT_INPUT_LIFT_VAR = "--chat-input-lift"; const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 350; @@ -39,6 +40,8 @@ export function useChatKeyboardAvoidance({ const activeRef = useRef(active); const baselineRef = useRef(null); + useChatKeyboardDiagnostics({ containerRef }); + useEffect(() => { activeRef.current = active; }, [active]); diff --git a/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts b/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts new file mode 100644 index 00000000..09b0bb73 --- /dev/null +++ b/src/app/chat/hooks/use-chat-keyboard-diagnostics.ts @@ -0,0 +1,203 @@ +"use client"; + +import { useEffect, type RefObject } from "react"; + +import { BrowserDetector } from "@/utils/browser-detect"; +import { PlatformDetector } from "@/utils/platform-detect"; + +const CHAT_KEYBOARD_DEBUG_PARAM = "keyboard_debug"; +const CHAT_INPUT_LIFT_VAR = "--chat-input-lift"; + +interface VirtualKeyboardLike extends EventTarget { + readonly boundingRect?: Pick< + DOMRectReadOnly, + "height" | "width" | "x" | "y" + >; +} + +interface NavigatorWithVirtualKeyboard extends Navigator { + readonly virtualKeyboard?: VirtualKeyboardLike; +} + +export interface ChatKeyboardDebugSnapshot { + event: string; + time: number; + windowInnerHeight: number; + documentClientHeight: number; + scrollY: number; + visualViewportSupported: boolean; + visualHeight: number | null; + visualWidth: number | null; + visualOffsetTop: number | null; + visualOffsetLeft: number | null; + visualScale: number | null; + virtualKeyboardSupported: boolean; + virtualKeyboardRect: { + x: number; + y: number; + width: number; + height: number; + } | null; + activeElement: string | null; + activeBottom: number | null; + activeFocused: boolean; + inputLift: string | null; + viewportMeta: string | null; + platform: { + name: string; + osName: string; + osVersion: string; + deviceVendor: string; + deviceModel: string; + }; + browser: { + name: string; + version: string; + inAppBrowserName: string | null; + }; +} + +declare global { + interface Window { + __keyboardDebugCleanup?: () => void; + __keyboardDebugSnapshot?: () => ChatKeyboardDebugSnapshot; + } +} + +export interface UseChatKeyboardDiagnosticsOptions { + containerRef: RefObject; +} + +export function useChatKeyboardDiagnostics({ + containerRef, +}: UseChatKeyboardDiagnosticsOptions): void { + useEffect(() => { + if (!isChatKeyboardDiagnosticsEnabled(window.location.search)) return; + return installChatKeyboardDiagnostics(containerRef); + }, [containerRef]); +} + +export function isChatKeyboardDiagnosticsEnabled(search: string): boolean { + return new URLSearchParams(search).get(CHAT_KEYBOARD_DEBUG_PARAM) === "1"; +} + +export function installChatKeyboardDiagnostics( + containerRef: RefObject, +): () => void { + window.__keyboardDebugCleanup?.(); + + const removers: Array<() => void> = []; + let disposed = false; + + const takeSnapshot = (event: string): ChatKeyboardDebugSnapshot => { + const snapshot = createChatKeyboardDebugSnapshot(event, containerRef); + window.console.info("[keyboard-debug]", snapshot); + return snapshot; + }; + + const listen = ( + target: EventTarget | null | undefined, + eventName: string, + label = eventName, + ) => { + if (!target) return; + const handler = () => takeSnapshot(label); + target.addEventListener(eventName, handler); + removers.push(() => target.removeEventListener(eventName, handler)); + }; + + const cleanup = () => { + if (disposed) return; + disposed = true; + removers.forEach((remove) => remove()); + if (window.__keyboardDebugSnapshot === manualSnapshot) { + delete window.__keyboardDebugSnapshot; + } + if (window.__keyboardDebugCleanup === cleanup) { + delete window.__keyboardDebugCleanup; + } + window.console.info("[keyboard-debug] removed"); + }; + const manualSnapshot = () => takeSnapshot("manual"); + const virtualKeyboard = getVirtualKeyboard(); + + listen(window, "resize", "window.resize"); + listen(window, "scroll", "window.scroll"); + listen(window, "orientationchange", "orientationchange"); + listen(document, "focusin", "focusin"); + listen(document, "focusout", "focusout"); + listen(document, "visibilitychange", "visibilitychange"); + listen(window.visualViewport, "resize", "visualViewport.resize"); + listen(window.visualViewport, "scroll", "visualViewport.scroll"); + listen(virtualKeyboard, "geometrychange", "virtualKeyboard.geometrychange"); + + window.__keyboardDebugSnapshot = manualSnapshot; + window.__keyboardDebugCleanup = cleanup; + takeSnapshot("installed"); + + return cleanup; +} + +function createChatKeyboardDebugSnapshot( + event: string, + containerRef: RefObject, +): ChatKeyboardDebugSnapshot { + const visualViewport = window.visualViewport; + const virtualKeyboardRect = getVirtualKeyboard()?.boundingRect ?? null; + const activeElement = document.activeElement; + const activeRect = + activeElement instanceof HTMLElement + ? activeElement.getBoundingClientRect() + : null; + const platform = PlatformDetector.getPlatformInfo(); + const browser = BrowserDetector.getBrowserInfo(); + + return { + event, + time: Math.round(performance.now()), + windowInnerHeight: window.innerHeight, + documentClientHeight: document.documentElement.clientHeight, + scrollY: window.scrollY, + visualViewportSupported: visualViewport != null, + visualHeight: visualViewport?.height ?? null, + visualWidth: visualViewport?.width ?? null, + visualOffsetTop: visualViewport?.offsetTop ?? null, + visualOffsetLeft: visualViewport?.offsetLeft ?? null, + visualScale: visualViewport?.scale ?? null, + virtualKeyboardSupported: getVirtualKeyboard() != null, + virtualKeyboardRect: virtualKeyboardRect + ? { + x: virtualKeyboardRect.x, + y: virtualKeyboardRect.y, + width: virtualKeyboardRect.width, + height: virtualKeyboardRect.height, + } + : null, + activeElement: activeElement?.tagName ?? null, + activeBottom: activeRect?.bottom ?? null, + activeFocused: activeElement !== document.body, + inputLift: + containerRef.current?.style.getPropertyValue(CHAT_INPUT_LIFT_VAR) || null, + viewportMeta: + document.querySelector('meta[name="viewport"]') + ?.content ?? null, + platform: { + name: platform.platform, + osName: platform.osName, + osVersion: platform.osVersion, + deviceVendor: platform.deviceVendor, + deviceModel: platform.deviceModel, + }, + browser: { + name: browser.name, + version: browser.version, + inAppBrowserName: browser.inAppBrowserName, + }, + }; +} + +function getVirtualKeyboard(): VirtualKeyboardLike | null { + return ( + (navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null + ); +}