fix(chat): restore Facebook keyboard avoidance

This commit is contained in:
2026-07-16 16:28:10 +08:00
parent 9d04176e90
commit 54b9031622
10 changed files with 642 additions and 121 deletions
@@ -1,11 +1,14 @@
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */ /* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
/* Keyboard inset is a total bottom inset, not an addition to normal spacing. */
.bar { .bar {
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 padding: 0
calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px)) calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px))
calc( max(
var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px) 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)); calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px));
background: transparent; background: transparent;
+5 -2
View File
@@ -4,8 +4,8 @@ import { useRef, useState } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance";
import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics"; 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 { ChatInputTextField } from "./chat-input-text-field";
import { ChatSendButton } from "./chat-send-button"; import { ChatSendButton } from "./chat-send-button";
import styles from "./chat-input-bar.module.css"; import styles from "./chat-input-bar.module.css";
@@ -27,7 +27,10 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const hasContent = input.trim().length > 0; const hasContent = input.trim().length > 0;
useChatVirtualKeyboardOverlayExperiment(); useChatKeyboardAvoidance({
active: isFocused,
containerRef: barRef,
});
useChatKeyboardDiagnostics({ containerRef: barRef }); useChatKeyboardDiagnostics({ containerRef: barRef });
const handleInputChange = (value: string) => { const handleInputChange = (value: string) => {
@@ -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> = {},
): 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);
});
});
@@ -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<HTMLDivElement>(null);
useChatKeyboardAvoidance({ active, containerRef });
return <div ref={containerRef} data-testid="chat-input" />;
}
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(<Harness active={active} />));
}
function getBottomInset(container: HTMLElement): string {
const input = container.querySelector<HTMLDivElement>(
'[data-testid="chat-input"]',
);
if (!input) throw new Error("Chat input test node was not rendered");
return input.style.getPropertyValue("--chat-keyboard-bottom-inset");
}
@@ -84,6 +84,7 @@ describe("chat keyboard diagnostics", () => {
const input = document.createElement("textarea"); const input = document.createElement("textarea");
document.documentElement.style.setProperty("--app-viewport-height", "800px"); document.documentElement.style.setProperty("--app-viewport-height", "800px");
document.documentElement.style.setProperty("--app-visible-height", "460px"); document.documentElement.style.setProperty("--app-visible-height", "460px");
input.style.setProperty("--chat-keyboard-bottom-inset", "300px");
document.body.append(input); document.body.append(input);
input.focus(); input.focus();
const inputRef = { current: input } as RefObject<HTMLElement | null>; const inputRef = { current: input } as RefObject<HTMLElement | null>;
@@ -103,6 +104,7 @@ describe("chat keyboard diagnostics", () => {
activeFocused: true, activeFocused: true,
inputBarTop: 0, inputBarTop: 0,
inputBarBottom: 0, inputBarBottom: 0,
keyboardBottomInset: "300px",
appViewportHeight: "800px", appViewportHeight: "800px",
appVisibleHeight: "460px", appVisibleHeight: "460px",
platform: { name: "android", deviceModel: "PLB110" }, platform: { name: "android", deviceModel: "PLB110" },
@@ -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);
});
});
@@ -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),
),
);
}
@@ -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<DOMRectReadOnly, "height">;
}
interface NavigatorWithVirtualKeyboard extends Navigator {
readonly virtualKeyboard?: VirtualKeyboardLike;
}
export interface UseChatKeyboardAvoidanceOptions {
active: boolean;
containerRef: RefObject<HTMLElement | null>;
}
export function useChatKeyboardAvoidance({
active,
containerRef,
}: UseChatKeyboardAvoidanceOptions): void {
const activeRef = useRef(active);
const baselineRef = useRef<ChatKeyboardViewportBaseline | null>(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);
}
@@ -44,6 +44,8 @@ export interface ChatKeyboardDebugSnapshot {
activeFocused: boolean; activeFocused: boolean;
inputBarTop: number | null; inputBarTop: number | null;
inputBarBottom: number | null; inputBarBottom: number | null;
inputBarPaddingBottom: string | null;
keyboardBottomInset: string | null;
appViewportHeight: string | null; appViewportHeight: string | null;
appVisibleHeight: string | null; appVisibleHeight: string | null;
appVisibleOffsetTop: string | null; appVisibleOffsetTop: string | null;
@@ -157,7 +159,9 @@ function createChatKeyboardDebugSnapshot(
const platform = PlatformDetector.getPlatformInfo(); const platform = PlatformDetector.getPlatformInfo();
const browser = BrowserDetector.getBrowserInfo(); const browser = BrowserDetector.getBrowserInfo();
const rootStyle = document.documentElement.style; 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 { return {
event, event,
@@ -187,6 +191,9 @@ function createChatKeyboardDebugSnapshot(
activeFocused: activeElement !== document.body, activeFocused: activeElement !== document.body,
inputBarTop: inputBarRect?.top ?? null, inputBarTop: inputBarRect?.top ?? null,
inputBarBottom: inputBarRect?.bottom ?? null, inputBarBottom: inputBarRect?.bottom ?? null,
inputBarPaddingBottom: inputBarStyle?.paddingBottom ?? null,
keyboardBottomInset:
inputBar?.style.getPropertyValue("--chat-keyboard-bottom-inset") || null,
appViewportHeight: appViewportHeight:
rootStyle.getPropertyValue("--app-viewport-height") || null, rootStyle.getPropertyValue("--app-viewport-height") || null,
appVisibleHeight: appVisibleHeight:
@@ -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;
}