fix(chat): replace Android keyboard workaround
This commit is contained in:
@@ -1,20 +1,13 @@
|
||||
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
|
||||
|
||||
/* 键盘适配:
|
||||
* --chat-input-lift 是小米 Android + Facebook IAB 的专用抬升值。
|
||||
* --chat-input-lift-effective 只有目标设备标记存在时才读取抬升值。
|
||||
* --app-safe-bottom 处理小米全面屏手势条 / iPhone home indicator。
|
||||
* 正常浏览器中 --chat-input-lift 为 0,因此无视觉副作用。 */
|
||||
:global(html[data-keyboard-adapt="android-facebook-target"]) {
|
||||
--chat-input-lift-effective: var(--chat-input-lift, 0px);
|
||||
}
|
||||
/* 键盘适配:--chat-input-lift 仅写入当前聊天输入栏。 */
|
||||
|
||||
.bar {
|
||||
flex: 0 0 auto;
|
||||
padding: 0
|
||||
calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px))
|
||||
calc(
|
||||
var(--spacing-lg, 16px) + var(--chat-input-lift-effective, 0px) +
|
||||
var(--spacing-lg, 16px) + var(--chat-input-lift, 0px) +
|
||||
var(--app-safe-bottom, 0px)
|
||||
)
|
||||
calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px));
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { useKeyboardHeight } from "@/hooks";
|
||||
import { Logger } from "@/utils/logger";
|
||||
|
||||
import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance";
|
||||
import { ChatInputTextField } from "./chat-input-text-field";
|
||||
import { ChatSendButton } from "./chat-send-button";
|
||||
import styles from "./chat-input-bar.module.css";
|
||||
@@ -20,13 +20,14 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
const dispatch = useChatDispatch();
|
||||
const [input, setInput] = useState("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lastPointerSendAtRef = useRef(0);
|
||||
|
||||
const hasContent = input.trim().length > 0;
|
||||
|
||||
useKeyboardHeight({
|
||||
targetRef: textareaRef,
|
||||
useChatKeyboardAvoidance({
|
||||
containerRef: barRef,
|
||||
active: isFocused,
|
||||
});
|
||||
|
||||
@@ -68,7 +69,10 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}>
|
||||
<div
|
||||
ref={barRef}
|
||||
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}
|
||||
>
|
||||
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
|
||||
<ChatInputTextField
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveChatKeyboardFallbackLift,
|
||||
resolveChatKeyboardLift,
|
||||
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("resolveChatKeyboardLift", () => {
|
||||
it("does not add lift when the content viewport already resized", () => {
|
||||
expect(
|
||||
resolveChatKeyboardLift(
|
||||
baseline,
|
||||
makeSnapshot({ layoutHeight: 500, visualHeight: 500 }),
|
||||
),
|
||||
).toEqual({
|
||||
keyboardVisible: true,
|
||||
lift: 0,
|
||||
source: "content-resize",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the visual viewport delta for an overlay keyboard", () => {
|
||||
expect(
|
||||
resolveChatKeyboardLift(
|
||||
baseline,
|
||||
makeSnapshot({ visualHeight: 460 }),
|
||||
),
|
||||
).toEqual({
|
||||
keyboardVisible: true,
|
||||
lift: 314,
|
||||
source: "visual-viewport",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers Virtual Keyboard geometry when available", () => {
|
||||
expect(
|
||||
resolveChatKeyboardLift(
|
||||
baseline,
|
||||
makeSnapshot({
|
||||
visualHeight: 520,
|
||||
virtualKeyboardHeight: 320,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
keyboardVisible: true,
|
||||
lift: 334,
|
||||
source: "virtual-keyboard",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores visual viewport changes caused by page zoom", () => {
|
||||
expect(
|
||||
resolveChatKeyboardLift(
|
||||
baseline,
|
||||
makeSnapshot({ visualHeight: 460, visualScale: 1.2 }),
|
||||
),
|
||||
).toEqual({
|
||||
keyboardVisible: false,
|
||||
lift: 0,
|
||||
source: "none",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveChatKeyboardFallbackLift", () => {
|
||||
it("scales with viewport height and remains bounded", () => {
|
||||
expect(resolveChatKeyboardFallbackLift(500)).toBe(260);
|
||||
expect(resolveChatKeyboardFallbackLift(800)).toBe(336);
|
||||
expect(resolveChatKeyboardFallbackLift(1_200)).toBe(380);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/* @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: {
|
||||
isInAppBrowser: 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;
|
||||
}
|
||||
|
||||
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 innerHeight: number;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.mocked(BrowserDetector.isInAppBrowser).mockReturnValue(true);
|
||||
innerHeight = 800;
|
||||
visualViewport = new MockVisualViewport();
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
get: () => innerHeight,
|
||||
});
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: visualViewport,
|
||||
});
|
||||
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 adaptive fallback without polling", () => {
|
||||
const intervalSpy = vi.spyOn(window, "setInterval");
|
||||
renderHarness(root, false);
|
||||
renderHarness(root, true);
|
||||
|
||||
act(() => vi.advanceTimersByTime(350));
|
||||
|
||||
expect(getInput(container).style.getPropertyValue("--chat-input-lift")).toBe(
|
||||
"336px",
|
||||
);
|
||||
expect(intervalSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not use the fallback in a regular Android browser", () => {
|
||||
vi.mocked(BrowserDetector.isInAppBrowser).mockReturnValue(false);
|
||||
renderHarness(root, false);
|
||||
renderHarness(root, true);
|
||||
|
||||
act(() => vi.advanceTimersByTime(350));
|
||||
|
||||
expect(getInput(container).style.getPropertyValue("--chat-input-lift")).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.runOnlyPendingTimers();
|
||||
});
|
||||
expect(getInput(container).style.getPropertyValue("--chat-input-lift")).toBe(
|
||||
"314px",
|
||||
);
|
||||
|
||||
visualViewport.height = 760;
|
||||
act(() => {
|
||||
visualViewport.dispatchEvent(new Event("resize"));
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expect(getInput(container).style.getPropertyValue("--chat-input-lift")).toBe(
|
||||
"0px",
|
||||
);
|
||||
|
||||
act(() => vi.advanceTimersByTime(1_000));
|
||||
expect(getInput(container).style.getPropertyValue("--chat-input-lift")).toBe(
|
||||
"0px",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add fallback lift when the layout viewport resized", () => {
|
||||
renderHarness(root, false);
|
||||
renderHarness(root, true);
|
||||
|
||||
innerHeight = 500;
|
||||
visualViewport.height = 500;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
expect(getInput(container).style.getPropertyValue("--chat-input-lift")).toBe(
|
||||
"0px",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the local lift when focus leaves or the hook unmounts", () => {
|
||||
renderHarness(root, false);
|
||||
renderHarness(root, true);
|
||||
act(() => vi.advanceTimersByTime(350));
|
||||
expect(getInput(container).style.getPropertyValue("--chat-input-lift")).toBe(
|
||||
"336px",
|
||||
);
|
||||
|
||||
renderHarness(root, false);
|
||||
expect(getInput(container).style.getPropertyValue("--chat-input-lift")).toBe(
|
||||
"",
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
expect(container.querySelector('[data-testid="chat-input"]')).toBeNull();
|
||||
root = createRoot(container);
|
||||
});
|
||||
});
|
||||
|
||||
function renderHarness(root: Root, active: boolean): void {
|
||||
act(() => root.render(<Harness active={active} />));
|
||||
}
|
||||
|
||||
function getInput(container: HTMLElement): HTMLDivElement {
|
||||
const input = container.querySelector<HTMLDivElement>(
|
||||
'[data-testid="chat-input"]',
|
||||
);
|
||||
if (!input) throw new Error("Chat input test node was not rendered");
|
||||
return input;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export const CHAT_KEYBOARD_FOCUS_GAP_PX = 14;
|
||||
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_LIFT_RATIO = 0.55;
|
||||
|
||||
export interface ChatKeyboardViewportBaseline {
|
||||
layoutHeight: number;
|
||||
visualHeight: number;
|
||||
}
|
||||
|
||||
export interface ChatKeyboardViewportSnapshot {
|
||||
layoutHeight: number;
|
||||
visualHeight: number;
|
||||
visualScale: number;
|
||||
virtualKeyboardHeight: number;
|
||||
}
|
||||
|
||||
export type ChatKeyboardLiftSource =
|
||||
| "content-resize"
|
||||
| "visual-viewport"
|
||||
| "virtual-keyboard"
|
||||
| "none";
|
||||
|
||||
export interface ChatKeyboardLiftResolution {
|
||||
keyboardVisible: boolean;
|
||||
lift: number;
|
||||
source: ChatKeyboardLiftSource;
|
||||
}
|
||||
|
||||
export function resolveChatKeyboardLift(
|
||||
baseline: ChatKeyboardViewportBaseline,
|
||||
snapshot: ChatKeyboardViewportSnapshot,
|
||||
): ChatKeyboardLiftResolution {
|
||||
const contentResize = baseline.layoutHeight - snapshot.layoutHeight;
|
||||
if (contentResize > CHAT_KEYBOARD_VISIBLE_THRESHOLD_PX) {
|
||||
return {
|
||||
keyboardVisible: true,
|
||||
lift: 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,
|
||||
lift: 0,
|
||||
source: "none",
|
||||
};
|
||||
}
|
||||
|
||||
const maxLift = Math.round(
|
||||
baseline.layoutHeight * CHAT_KEYBOARD_MAX_LIFT_RATIO,
|
||||
);
|
||||
|
||||
return {
|
||||
keyboardVisible: true,
|
||||
lift: Math.min(
|
||||
maxLift,
|
||||
Math.round(occludedHeight + CHAT_KEYBOARD_FOCUS_GAP_PX),
|
||||
),
|
||||
source:
|
||||
virtualKeyboardHeight >= visualViewportHeight
|
||||
? "virtual-keyboard"
|
||||
: "visual-viewport",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveChatKeyboardFallbackLift(
|
||||
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,243 @@
|
||||
"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 {
|
||||
resolveChatKeyboardFallbackLift,
|
||||
resolveChatKeyboardLift,
|
||||
type ChatKeyboardLiftResolution,
|
||||
type ChatKeyboardViewportBaseline,
|
||||
type ChatKeyboardViewportSnapshot,
|
||||
} from "./chat-keyboard-geometry";
|
||||
|
||||
const CHAT_INPUT_LIFT_VAR = "--chat-input-lift";
|
||||
const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 350;
|
||||
|
||||
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()) {
|
||||
clearInputLift(container);
|
||||
if (!active) baselineRef.current = readViewportBaseline();
|
||||
return;
|
||||
}
|
||||
|
||||
const baseline = baselineRef.current ?? readViewportBaseline();
|
||||
const visualViewport = window.visualViewport;
|
||||
const virtualKeyboard = getVirtualKeyboard();
|
||||
const fallbackAllowed = BrowserDetector.isInAppBrowser();
|
||||
let frameId: number | null = null;
|
||||
let fallbackTimerId: number | null = null;
|
||||
let lastLift = -1;
|
||||
let pendingCanDismiss = false;
|
||||
let pendingReason = "initial";
|
||||
let keyboardObserved = false;
|
||||
let fallbackApplied = false;
|
||||
let sessionDismissed = false;
|
||||
|
||||
const applyLift = (
|
||||
lift: number,
|
||||
source: ChatKeyboardLiftResolution["source"] | "fallback" | "reset",
|
||||
) => {
|
||||
const nextLift = Math.max(0, Math.round(lift));
|
||||
if (nextLift === lastLift) return;
|
||||
lastLift = nextLift;
|
||||
container.style.setProperty(CHAT_INPUT_LIFT_VAR, `${nextLift}px`);
|
||||
log.debug("[chat-keyboard] apply", {
|
||||
source,
|
||||
lift: nextLift,
|
||||
});
|
||||
};
|
||||
|
||||
const clearFallbackTimer = () => {
|
||||
if (fallbackTimerId == null) return;
|
||||
window.clearTimeout(fallbackTimerId);
|
||||
fallbackTimerId = null;
|
||||
};
|
||||
|
||||
const measure = (reason: string, canDismiss: boolean) => {
|
||||
if (sessionDismissed) return;
|
||||
|
||||
const resolution = resolveChatKeyboardLift(
|
||||
baseline,
|
||||
readViewportSnapshot(),
|
||||
);
|
||||
|
||||
if (resolution.keyboardVisible) {
|
||||
keyboardObserved = true;
|
||||
applyLift(resolution.lift, resolution.source);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((keyboardObserved || fallbackApplied) && canDismiss) {
|
||||
sessionDismissed = true;
|
||||
clearFallbackTimer();
|
||||
applyLift(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();
|
||||
applyLift(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 = resolveChatKeyboardLift(
|
||||
baseline,
|
||||
readViewportSnapshot(),
|
||||
);
|
||||
if (resolution.keyboardVisible) {
|
||||
keyboardObserved = true;
|
||||
applyLift(resolution.lift, resolution.source);
|
||||
return;
|
||||
}
|
||||
if (!fallbackAllowed) return;
|
||||
|
||||
fallbackApplied = true;
|
||||
applyLift(
|
||||
resolveChatKeyboardFallbackLift(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);
|
||||
clearInputLift(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 clearInputLift(container: HTMLElement): void {
|
||||
container.style.removeProperty(CHAT_INPUT_LIFT_VAR);
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ChatRouteProviders } from "@/providers/chat-route-providers";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
interactiveWidget: "resizes-content",
|
||||
};
|
||||
|
||||
export default function ChatLayout({ children }: { children: ReactNode }) {
|
||||
return <ChatRouteProviders>{children}</ChatRouteProviders>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user