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
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;
}