From 5934d480340d7f7f7de739e242e13dd50b72aee8 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 16 Jul 2026 13:18:26 +0800 Subject: [PATCH] fix(chat): replace Android keyboard workaround --- .../chat/components/chat-input-bar.module.css | 11 +- src/app/chat/components/chat-input-bar.tsx | 12 +- .../__tests__/chat-keyboard-geometry.test.ts | 90 +++++++ .../use-chat-keyboard-avoidance.test.tsx | 178 +++++++++++++ src/app/chat/hooks/chat-keyboard-geometry.ts | 95 +++++++ .../chat/hooks/use-chat-keyboard-avoidance.ts | 243 ++++++++++++++++++ src/app/chat/layout.tsx | 5 + src/hooks/index.ts | 1 - .../__tests__/environment.test.ts | 136 ---------- src/hooks/use-keyboard-height/constants.ts | 10 - src/hooks/use-keyboard-height/css-vars.ts | 16 -- src/hooks/use-keyboard-height/environment.ts | 40 --- src/hooks/use-keyboard-height/index.ts | 201 --------------- .../use-keyboard-height/keyboard-target.ts | 6 - src/hooks/use-keyboard-height/types.ts | 16 -- src/utils/__tests__/platform-detect.test.ts | 61 ----- src/utils/platform-detect.ts | 32 --- 17 files changed, 621 insertions(+), 532 deletions(-) create mode 100644 src/app/chat/hooks/__tests__/chat-keyboard-geometry.test.ts create mode 100644 src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx create mode 100644 src/app/chat/hooks/chat-keyboard-geometry.ts create mode 100644 src/app/chat/hooks/use-chat-keyboard-avoidance.ts delete mode 100644 src/hooks/use-keyboard-height/__tests__/environment.test.ts delete mode 100644 src/hooks/use-keyboard-height/constants.ts delete mode 100644 src/hooks/use-keyboard-height/css-vars.ts delete mode 100644 src/hooks/use-keyboard-height/environment.ts delete mode 100644 src/hooks/use-keyboard-height/index.ts delete mode 100644 src/hooks/use-keyboard-height/keyboard-target.ts delete mode 100644 src/hooks/use-keyboard-height/types.ts delete mode 100644 src/utils/__tests__/platform-detect.test.ts diff --git a/src/app/chat/components/chat-input-bar.module.css b/src/app/chat/components/chat-input-bar.module.css index 494a00bb..f33a9f44 100644 --- a/src/app/chat/components/chat-input-bar.module.css +++ b/src/app/chat/components/chat-input-bar.module.css @@ -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)); diff --git a/src/app/chat/components/chat-input-bar.tsx b/src/app/chat/components/chat-input-bar.tsx index a7a42481..05021634 100644 --- a/src/app/chat/components/chat-input-bar.tsx +++ b/src/app/chat/components/chat-input-bar.tsx @@ -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(null); const textareaRef = useRef(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 ( -
+
= {}, +): 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); + }); +}); diff --git a/src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx b/src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx new file mode 100644 index 00000000..4eccba69 --- /dev/null +++ b/src/app/chat/hooks/__tests__/use-chat-keyboard-avoidance.test.tsx @@ -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(null); + useChatKeyboardAvoidance({ active, containerRef }); + return
; +} + +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()); +} + +function getInput(container: HTMLElement): HTMLDivElement { + const input = container.querySelector( + '[data-testid="chat-input"]', + ); + if (!input) throw new Error("Chat input test node was not rendered"); + return input; +} diff --git a/src/app/chat/hooks/chat-keyboard-geometry.ts b/src/app/chat/hooks/chat-keyboard-geometry.ts new file mode 100644 index 00000000..970cbe82 --- /dev/null +++ b/src/app/chat/hooks/chat-keyboard-geometry.ts @@ -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), + ), + ); +} diff --git a/src/app/chat/hooks/use-chat-keyboard-avoidance.ts b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts new file mode 100644 index 00000000..7e0cbb62 --- /dev/null +++ b/src/app/chat/hooks/use-chat-keyboard-avoidance.ts @@ -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; +} + +interface NavigatorWithVirtualKeyboard extends Navigator { + readonly virtualKeyboard?: VirtualKeyboardLike; +} + +export interface UseChatKeyboardAvoidanceOptions { + active: boolean; + containerRef: RefObject; +} + +export function useChatKeyboardAvoidance({ + active, + containerRef, +}: UseChatKeyboardAvoidanceOptions): void { + const activeRef = useRef(active); + const baselineRef = useRef(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); +} diff --git a/src/app/chat/layout.tsx b/src/app/chat/layout.tsx index 275a186c..048d0789 100644 --- a/src/app/chat/layout.tsx +++ b/src/app/chat/layout.tsx @@ -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 {children}; } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index fa46bad9..7f130f8c 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,5 +4,4 @@ export * from "./use-breakpoint"; export * from "./use-has-hydrated"; -export * from "./use-keyboard-height"; export * from "./use-pull-to-refresh"; diff --git a/src/hooks/use-keyboard-height/__tests__/environment.test.ts b/src/hooks/use-keyboard-height/__tests__/environment.test.ts deleted file mode 100644 index 0aeb750f..00000000 --- a/src/hooks/use-keyboard-height/__tests__/environment.test.ts +++ /dev/null @@ -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 { - 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); - }); -}); diff --git a/src/hooks/use-keyboard-height/constants.ts b/src/hooks/use-keyboard-height/constants.ts deleted file mode 100644 index a0804f60..00000000 --- a/src/hooks/use-keyboard-height/constants.ts +++ /dev/null @@ -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; diff --git a/src/hooks/use-keyboard-height/css-vars.ts b/src/hooks/use-keyboard-height/css-vars.ts deleted file mode 100644 index 5ec07b8e..00000000 --- a/src/hooks/use-keyboard-height/css-vars.ts +++ /dev/null @@ -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]; -} diff --git a/src/hooks/use-keyboard-height/environment.ts b/src/hooks/use-keyboard-height/environment.ts deleted file mode 100644 index f3f083c5..00000000 --- a/src/hooks/use-keyboard-height/environment.ts +++ /dev/null @@ -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) - ); -} diff --git a/src/hooks/use-keyboard-height/index.ts b/src/hooks/use-keyboard-height/index.ts deleted file mode 100644 index 30b3d66a..00000000 --- a/src/hooks/use-keyboard-height/index.ts +++ /dev/null @@ -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 }; diff --git a/src/hooks/use-keyboard-height/keyboard-target.ts b/src/hooks/use-keyboard-height/keyboard-target.ts deleted file mode 100644 index a8fb412e..00000000 --- a/src/hooks/use-keyboard-height/keyboard-target.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function isKeyboardTargetActive( - target: HTMLElement | null | undefined, - reactActive: boolean, -): boolean { - return reactActive || (target != null && document.activeElement === target); -} diff --git a/src/hooks/use-keyboard-height/types.ts b/src/hooks/use-keyboard-height/types.ts deleted file mode 100644 index 3cf764da..00000000 --- a/src/hooks/use-keyboard-height/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { RefObject } from "react"; - -export interface UseKeyboardHeightOptions { - targetRef?: RefObject; - active?: boolean; -} - -export interface KeyboardAdaptEnvironment { - isAndroid: boolean; - isIOS: boolean; - inAppBrowserName: string | null; - isXiaomiFamilyDevice: boolean; - isOppoFamilyDevice: boolean; - isFacebookInAppBrowser: boolean; - isTargetAndroidFacebookDevice: boolean; -} diff --git a/src/utils/__tests__/platform-detect.test.ts b/src/utils/__tests__/platform-detect.test.ts deleted file mode 100644 index 439b2fea..00000000 --- a/src/utils/__tests__/platform-detect.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/utils/platform-detect.ts b/src/utils/platform-detect.ts index 2cc710bc..d512377d 100644 --- a/src/utils/platform-detect.ts +++ b/src/utils/platform-detect.ts @@ -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(), - ); -}