fix(chat): replace Android keyboard workaround

This commit is contained in:
2026-07-16 13:18:26 +08:00
parent 1c8a6afce1
commit 5934d48034
17 changed files with 621 additions and 532 deletions
@@ -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));
+8 -4
View File
@@ -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);
}
+5
View File
@@ -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>;
}
-1
View File
@@ -4,5 +4,4 @@
export * from "./use-breakpoint";
export * from "./use-has-hydrated";
export * from "./use-keyboard-height";
export * from "./use-pull-to-refresh";
@@ -1,136 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getKeyboardAdaptEnvironment,
shouldEnableKeyboardAdaptation,
} from "../environment";
import type { KeyboardAdaptEnvironment } from "../types";
function makeEnvironment(
overrides: Partial<KeyboardAdaptEnvironment> = {},
): KeyboardAdaptEnvironment {
return {
isAndroid: true,
isIOS: false,
inAppBrowserName: "facebook",
isXiaomiFamilyDevice: true,
isOppoFamilyDevice: false,
isFacebookInAppBrowser: true,
isTargetAndroidFacebookDevice: true,
...overrides,
};
}
describe("shouldEnableKeyboardAdaptation", () => {
it("enables for Xiaomi Android Facebook in-app browser fallback", () => {
expect(shouldEnableKeyboardAdaptation(makeEnvironment())).toBe(true);
});
it("enables for OPPO Android Facebook in-app browser fallback", () => {
expect(
shouldEnableKeyboardAdaptation(
makeEnvironment({
isXiaomiFamilyDevice: false,
isOppoFamilyDevice: true,
isTargetAndroidFacebookDevice: true,
}),
),
).toBe(true);
});
it("disables for non-target platforms, browsers, and devices", () => {
expect(
shouldEnableKeyboardAdaptation(
makeEnvironment({
isAndroid: false,
isIOS: true,
isXiaomiFamilyDevice: false,
isOppoFamilyDevice: false,
isTargetAndroidFacebookDevice: false,
}),
),
).toBe(false);
expect(
shouldEnableKeyboardAdaptation(
makeEnvironment({
inAppBrowserName: "instagram",
isFacebookInAppBrowser: false,
isTargetAndroidFacebookDevice: false,
}),
),
).toBe(false);
expect(
shouldEnableKeyboardAdaptation(
makeEnvironment({
isXiaomiFamilyDevice: false,
isOppoFamilyDevice: false,
isTargetAndroidFacebookDevice: false,
}),
),
).toBe(false);
});
it("disables for iOS Facebook even when Facebook IAB is detected", () => {
expect(
shouldEnableKeyboardAdaptation(
makeEnvironment({
isAndroid: false,
isIOS: true,
inAppBrowserName: "facebook",
isXiaomiFamilyDevice: false,
isOppoFamilyDevice: false,
isFacebookInAppBrowser: true,
isTargetAndroidFacebookDevice: false,
}),
),
).toBe(false);
});
it("does not trust target flag without the Android target device fields", () => {
expect(
shouldEnableKeyboardAdaptation(
makeEnvironment({
isAndroid: false,
isIOS: true,
isFacebookInAppBrowser: true,
isXiaomiFamilyDevice: true,
isTargetAndroidFacebookDevice: true,
}),
),
).toBe(false);
});
it("detects iPhone Facebook in-app browser as non-target environment", () => {
const environment = getKeyboardAdaptEnvironment(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) " +
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 " +
"[FBAN/FBIOS;FBAV/466.0.0.49.109;FBBV/605000000;FBDV/iPhone15,2]",
);
expect(environment).toMatchObject({
isAndroid: false,
isIOS: true,
isFacebookInAppBrowser: true,
isTargetAndroidFacebookDevice: false,
});
expect(shouldEnableKeyboardAdaptation(environment)).toBe(false);
});
it("enables the fallback for OnePlus PLB110 Facebook in-app browser", () => {
const environment = getKeyboardAdaptEnvironment(
"Mozilla/5.0 (Linux; Android 15; PLB110 Build/AP3A.240617.008; wv) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " +
"Chrome/126.0.0.0 Mobile Safari/537.36 FBAN/FB4A",
);
expect(environment).toMatchObject({
isAndroid: true,
isOppoFamilyDevice: true,
isFacebookInAppBrowser: true,
isTargetAndroidFacebookDevice: true,
});
expect(shouldEnableKeyboardAdaptation(environment)).toBe(true);
});
});
@@ -1,10 +0,0 @@
export const INPUT_LIFT_VAR = "--chat-input-lift";
export const KEYBOARD_ADAPT_DATA_ATTR = "keyboardAdapt";
export const KEYBOARD_ADAPT_TARGET_VALUE = "android-facebook-target";
export const FOCUS_GAP_PX = 14;
export const FALLBACK_INPUT_LIFT_PX = 320;
export const KEYBOARD_VISIBLE_THRESHOLD_PX = 80;
export const KEYBOARD_VISIBLE_RATIO = 0.82;
export const KEYBOARD_MEASURE_DELAYS_MS = [0, 100, 250, 500];
export const KEYBOARD_POLL_INTERVAL_MS = 300;
-16
View File
@@ -1,16 +0,0 @@
import {
INPUT_LIFT_VAR,
KEYBOARD_ADAPT_DATA_ATTR,
KEYBOARD_ADAPT_TARGET_VALUE,
} from "./constants";
export function setInputLiftVar(lift: number): void {
document.documentElement.style.setProperty(INPUT_LIFT_VAR, `${lift}px`);
document.documentElement.dataset[KEYBOARD_ADAPT_DATA_ATTR] =
KEYBOARD_ADAPT_TARGET_VALUE;
}
export function resetInputLiftVar(): void {
document.documentElement.style.setProperty(INPUT_LIFT_VAR, "0px");
delete document.documentElement.dataset[KEYBOARD_ADAPT_DATA_ATTR];
}
@@ -1,40 +0,0 @@
import { BrowserDetector } from "@/utils/browser-detect";
import { PlatformDetector } from "@/utils/platform-detect";
import type { KeyboardAdaptEnvironment } from "./types";
export function getKeyboardAdaptEnvironment(
ua: string | null = null,
): KeyboardAdaptEnvironment {
const platform = PlatformDetector.getPlatformInfo(ua);
const browser = BrowserDetector.getBrowserInfo(ua);
const isXiaomiFamilyDevice = PlatformDetector.isXiaomiFamilyDevice(ua);
const isOppoFamilyDevice = PlatformDetector.isOppoFamilyDevice(ua);
const isFacebookInAppBrowser = BrowserDetector.isFacebookInAppBrowser(ua);
const isTargetAndroidFacebookDevice =
platform.isAndroid &&
!platform.isIOS &&
(isXiaomiFamilyDevice || isOppoFamilyDevice) &&
isFacebookInAppBrowser;
return {
isAndroid: platform.isAndroid,
isIOS: platform.isIOS,
inAppBrowserName: browser.inAppBrowserName,
isXiaomiFamilyDevice,
isOppoFamilyDevice,
isFacebookInAppBrowser,
isTargetAndroidFacebookDevice,
};
}
export function shouldEnableKeyboardAdaptation(
environment: KeyboardAdaptEnvironment,
): boolean {
return (
environment.isAndroid &&
!environment.isIOS &&
environment.isFacebookInAppBrowser &&
(environment.isXiaomiFamilyDevice || environment.isOppoFamilyDevice)
);
}
-201
View File
@@ -1,201 +0,0 @@
"use client";
/**
* 特定 Android 设备 + Facebook IAB 专用输入框避让 hook。
*
* 这不是通用软键盘方案。它只解决一个已知 WebView 问题:
* 输入框 focus 后键盘弹出,但 Facebook IAB 没有稳定调整页面 viewport
* 导致底部输入框被键盘遮挡。
*
* 策略:
* - 非目标 Android Facebook IAB 设备:完全不生效。
* - focus 后短期多次测量输入框是否被 visibleViewport 遮挡。
* - 若 viewport 没有可靠变化,则使用固定 fallback 抬升。
* - 聚焦期间轻量轮询,持续保持输入框避让。
*/
import { useEffect } from "react";
import { Logger } from "@/utils/logger";
import {
FALLBACK_INPUT_LIFT_PX,
FOCUS_GAP_PX,
KEYBOARD_MEASURE_DELAYS_MS,
KEYBOARD_POLL_INTERVAL_MS,
KEYBOARD_VISIBLE_RATIO,
KEYBOARD_VISIBLE_THRESHOLD_PX,
} from "./constants";
import {
resetInputLiftVar,
setInputLiftVar,
} from "./css-vars";
import {
getKeyboardAdaptEnvironment,
shouldEnableKeyboardAdaptation,
} from "./environment";
import { isKeyboardTargetActive } from "./keyboard-target";
import type { UseKeyboardHeightOptions } from "./types";
const log = new Logger("HooksUseKeyboardHeight");
interface KeyboardSnapshot {
layoutHeight: number;
visibleHeight: number;
visibleBottom: number;
keyboardHeight: number;
keyboardVisible: boolean;
}
export function useKeyboardHeight({
targetRef,
active = false,
}: UseKeyboardHeightOptions = {}): void {
useEffect(() => {
if (typeof window === "undefined") return;
const environment = getKeyboardAdaptEnvironment();
if (!shouldEnableKeyboardAdaptation(environment)) {
resetInputLiftVar();
log.debug("[keyboard] disabled", {
reason: "not-android-facebook-fallback-device",
environment,
});
return;
}
let rafId: number | null = null;
let pollId: number | null = null;
let timeoutIds: number[] = [];
let lastInputLift = -1;
log.debug("[keyboard] environment", environment);
const clearTimers = () => {
timeoutIds.forEach((id) => window.clearTimeout(id));
timeoutIds = [];
if (pollId != null) {
window.clearInterval(pollId);
pollId = null;
}
};
const applyLift = (lift: number, reason: string) => {
const nextLift = Math.max(0, Math.round(lift));
if (nextLift === lastInputLift) return;
lastInputLift = nextLift;
setInputLiftVar(nextLift);
log.debug("[keyboard] apply", {
reason,
inputLift: nextLift,
fallback: nextLift === FALLBACK_INPUT_LIFT_PX,
snapshot: formatSnapshot(readKeyboardSnapshot()),
});
};
const measure = (reason: string) => {
rafId = null;
const target = targetRef?.current;
const activeNow = isKeyboardTargetActive(target, active);
const snapshot = readKeyboardSnapshot();
if (!activeNow) {
log.debug("[keyboard] skip inactive target", {
reason,
snapshot: formatSnapshot(snapshot),
});
return;
}
const overlapLift = computeTargetOverlap(target, snapshot.visibleBottom);
const fallbackLift = snapshot.keyboardVisible ? 0 : FALLBACK_INPUT_LIFT_PX;
applyLift(Math.max(overlapLift, fallbackLift), reason);
};
const scheduleMeasure = (reason: string) => {
if (rafId != null) return;
rafId = window.requestAnimationFrame(() => measure(reason));
};
const scheduleInitialMeasures = () => {
timeoutIds = KEYBOARD_MEASURE_DELAYS_MS.map((delay) =>
window.setTimeout(() => scheduleMeasure(`focus-${delay}`), delay),
);
};
const handleViewportSignal = () => {
scheduleMeasure("viewport-signal");
};
if (!active) {
return;
}
const visualViewport = window.visualViewport;
visualViewport?.addEventListener("resize", handleViewportSignal);
visualViewport?.addEventListener("scroll", handleViewportSignal);
window.addEventListener("resize", handleViewportSignal);
window.addEventListener("orientationchange", handleViewportSignal);
document.addEventListener("visibilitychange", handleViewportSignal);
scheduleInitialMeasures();
pollId = window.setInterval(
() => scheduleMeasure("poll"),
KEYBOARD_POLL_INTERVAL_MS,
);
return () => {
if (rafId != null) window.cancelAnimationFrame(rafId);
clearTimers();
visualViewport?.removeEventListener("resize", handleViewportSignal);
visualViewport?.removeEventListener("scroll", handleViewportSignal);
window.removeEventListener("resize", handleViewportSignal);
window.removeEventListener("orientationchange", handleViewportSignal);
document.removeEventListener("visibilitychange", handleViewportSignal);
resetInputLiftVar();
};
}, [active, targetRef]);
}
function readKeyboardSnapshot(): KeyboardSnapshot {
const layoutHeight = window.innerHeight;
const visualViewport = window.visualViewport;
const visibleHeight = visualViewport?.height ?? layoutHeight;
const visibleBottom = visualViewport
? visualViewport.offsetTop + visualViewport.height
: layoutHeight;
const keyboardHeight = Math.max(0, layoutHeight - visibleBottom);
const keyboardVisible =
keyboardHeight > KEYBOARD_VISIBLE_THRESHOLD_PX ||
visibleHeight < layoutHeight * KEYBOARD_VISIBLE_RATIO;
return {
layoutHeight,
visibleHeight,
visibleBottom,
keyboardHeight,
keyboardVisible,
};
}
function computeTargetOverlap(
target: HTMLElement | null | undefined,
visibleBottom: number,
): number {
if (!target) return 0;
const rect = target.getBoundingClientRect();
return Math.max(0, rect.bottom + FOCUS_GAP_PX - visibleBottom);
}
function formatSnapshot(snapshot: KeyboardSnapshot) {
return {
layoutHeight: Math.round(snapshot.layoutHeight),
visibleHeight: Math.round(snapshot.visibleHeight),
visibleBottom: Math.round(snapshot.visibleBottom),
keyboardHeight: Math.round(snapshot.keyboardHeight),
keyboardVisible: snapshot.keyboardVisible,
};
}
export type { UseKeyboardHeightOptions };
@@ -1,6 +0,0 @@
export function isKeyboardTargetActive(
target: HTMLElement | null | undefined,
reactActive: boolean,
): boolean {
return reactActive || (target != null && document.activeElement === target);
}
-16
View File
@@ -1,16 +0,0 @@
import type { RefObject } from "react";
export interface UseKeyboardHeightOptions {
targetRef?: RefObject<HTMLElement | null>;
active?: boolean;
}
export interface KeyboardAdaptEnvironment {
isAndroid: boolean;
isIOS: boolean;
inAppBrowserName: string | null;
isXiaomiFamilyDevice: boolean;
isOppoFamilyDevice: boolean;
isFacebookInAppBrowser: boolean;
isTargetAndroidFacebookDevice: boolean;
}
@@ -1,61 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isOnePlusKeyboardFallbackDevice,
PlatformDetector,
} from "../platform-detect";
function createOnePlusFacebookUa(model: string): string {
return (
`Mozilla/5.0 (Linux; Android 15; ${model} Build/AP3A.240617.008; wv) ` +
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " +
"Chrome/126.0.0.0 Mobile Safari/537.36 FBAN/FB4A"
);
}
describe("PlatformDetector", () => {
it("treats OnePlus PKC110 as an OPPO-family keyboard fallback device", () => {
const ua = createOnePlusFacebookUa("PKC110");
expect(
isOnePlusKeyboardFallbackDevice({
model: "PKC110",
vendor: "OnePlus",
}),
).toBe(true);
expect(PlatformDetector.isOppoFamilyDevice(ua)).toBe(true);
});
it("treats OnePlus PLB110 as an OPPO-family keyboard fallback device", () => {
const ua = createOnePlusFacebookUa("PLB110");
expect(
isOnePlusKeyboardFallbackDevice({
model: "PLB110",
vendor: "OnePlus",
}),
).toBe(true);
expect(PlatformDetector.isOppoFamilyDevice(ua)).toBe(true);
});
it("does not treat other OnePlus models as OPPO-family fallback devices", () => {
const ua = createOnePlusFacebookUa("NE2210");
expect(
isOnePlusKeyboardFallbackDevice({
model: "NE2210",
vendor: "OnePlus",
}),
).toBe(false);
expect(PlatformDetector.isOppoFamilyDevice(ua)).toBe(false);
});
it("keeps OPPO CPH models as OPPO-family fallback devices", () => {
const ua =
"Mozilla/5.0 (Linux; Android 15; OPPO CPH2609 Build/AP3A.240617.008; wv) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " +
"Chrome/126.0.0.0 Mobile Safari/537.36 FBAN/FB4A";
expect(PlatformDetector.isOppoFamilyDevice(ua)).toBe(true);
});
});
-32
View File
@@ -1,7 +1,5 @@
import { UAParser } from "ua-parser-js";
const ONEPLUS_KEYBOARD_FALLBACK_MODELS = new Set(["PKC110", "PLB110"]);
export type PlatformName = "ios" | "android" | "desktop" | "unknown";
export interface PlatformInfo {
@@ -87,27 +85,6 @@ export class PlatformDetector {
return PlatformDetector.getPlatformInfo(ua).isDesktop;
}
static isXiaomiFamilyDevice(ua: string | null = null): boolean {
const info = PlatformDetector.getPlatformInfo(ua);
const target = `${info.deviceVendor} ${info.deviceModel} ${info.userAgent}`;
return /\b(Xiaomi|Redmi|POCO|Mi\s|MIX|MIUI)\b/i.test(target);
}
static isOppoFamilyDevice(ua: string | null = null): boolean {
const info = PlatformDetector.getPlatformInfo(ua);
const target = `${info.deviceVendor} ${info.deviceModel} ${info.userAgent}`;
const isOnePlusFamily = /\bOnePlus\b/i.test(
`${info.deviceVendor} ${info.userAgent}`,
);
if (isOnePlusFamily) {
return isOnePlusKeyboardFallbackDevice({
model: info.deviceModel,
vendor: info.deviceVendor,
});
}
return /\b(OPPO|OPlus|ColorOS|CPH\d{3,})\b/i.test(target);
}
private static getUserAgent(ua: string | null = null): string {
if (ua) return ua;
if (typeof navigator === "undefined") return "";
@@ -119,12 +96,3 @@ export class PlatformDetector {
return navigator.platform === "MacIntel" && (navigator.maxTouchPoints ?? 0) > 1;
}
}
export function isOnePlusKeyboardFallbackDevice(
device: { model: string; vendor: string },
): boolean {
if (!/\bOnePlus\b/i.test(device.vendor)) return false;
return ONEPLUS_KEYBOARD_FALLBACK_MODELS.has(
device.model.trim().toUpperCase(),
);
}