feat(chat): add keyboard diagnostics
This commit is contained in:
@@ -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<typeof vi.spyOn>;
|
||||||
|
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<HTMLElement | null>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type ChatKeyboardViewportBaseline,
|
type ChatKeyboardViewportBaseline,
|
||||||
type ChatKeyboardViewportSnapshot,
|
type ChatKeyboardViewportSnapshot,
|
||||||
} from "./chat-keyboard-geometry";
|
} from "./chat-keyboard-geometry";
|
||||||
|
import { useChatKeyboardDiagnostics } from "./use-chat-keyboard-diagnostics";
|
||||||
|
|
||||||
const CHAT_INPUT_LIFT_VAR = "--chat-input-lift";
|
const CHAT_INPUT_LIFT_VAR = "--chat-input-lift";
|
||||||
const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 350;
|
const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 350;
|
||||||
@@ -39,6 +40,8 @@ export function useChatKeyboardAvoidance({
|
|||||||
const activeRef = useRef(active);
|
const activeRef = useRef(active);
|
||||||
const baselineRef = useRef<ChatKeyboardViewportBaseline | null>(null);
|
const baselineRef = useRef<ChatKeyboardViewportBaseline | null>(null);
|
||||||
|
|
||||||
|
useChatKeyboardDiagnostics({ containerRef });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeRef.current = active;
|
activeRef.current = active;
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|||||||
@@ -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<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLElement | null>,
|
||||||
|
): () => 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<HTMLElement | null>,
|
||||||
|
): 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<HTMLMetaElement>('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
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user