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