fix(chat): restore Facebook keyboard avoidance
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user