"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 { resolveChatKeyboardFallbackInset, resolveChatKeyboardInset, type ChatKeyboardInsetResolution, type ChatKeyboardViewportBaseline, type ChatKeyboardViewportSnapshot, } from "./chat-keyboard-geometry"; const CHAT_KEYBOARD_BOTTOM_INSET_VAR = "--chat-keyboard-bottom-inset"; const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 150; 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" || !isKeyboardAvoidanceEnabled()) { 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; const enabled = isKeyboardAvoidanceEnabled(); if (!active || !enabled) { clearBottomInset(container); if (!active && enabled) baselineRef.current = readViewportBaseline(); return; } const baseline = baselineRef.current ?? readViewportBaseline(); const visualViewport = window.visualViewport; const virtualKeyboard = getVirtualKeyboard(); let frameId: number | null = null; let fallbackTimerId: number | null = null; let lastInset = -1; let pendingCanDismiss = false; let pendingReason = "initial"; let keyboardObserved = false; let fallbackApplied = false; let sessionDismissed = false; const applyBottomInset = ( bottomInset: number, source: ChatKeyboardInsetResolution["source"] | "fallback" | "reset", ) => { const nextInset = Math.max(0, Math.round(bottomInset)); if (nextInset === lastInset) return; lastInset = nextInset; container.style.setProperty( CHAT_KEYBOARD_BOTTOM_INSET_VAR, `${nextInset}px`, ); log.debug("[chat-keyboard] apply", { source, bottomInset: nextInset, }); }; const clearFallbackTimer = () => { if (fallbackTimerId == null) return; window.clearTimeout(fallbackTimerId); fallbackTimerId = null; }; const measure = (reason: string, canDismiss: boolean) => { if (sessionDismissed) return; const resolution = resolveChatKeyboardInset( baseline, readViewportSnapshot(), ); if (resolution.keyboardVisible) { keyboardObserved = true; clearFallbackTimer(); applyBottomInset(resolution.bottomInset, resolution.source); return; } if ((keyboardObserved || fallbackApplied) && canDismiss) { sessionDismissed = true; clearFallbackTimer(); applyBottomInset(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(); applyBottomInset(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 = resolveChatKeyboardInset( baseline, readViewportSnapshot(), ); if (resolution.keyboardVisible) { keyboardObserved = true; applyBottomInset(resolution.bottomInset, resolution.source); return; } fallbackApplied = true; applyBottomInset( resolveChatKeyboardFallbackInset(baseline), "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); clearBottomInset(container); }; }, [active, containerRef]); } function readViewportBaseline(): ChatKeyboardViewportBaseline { return { layoutHeight: window.innerHeight, layoutWidth: window.innerWidth, 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 clearBottomInset(container: HTMLElement): void { container.style.removeProperty(CHAT_KEYBOARD_BOTTOM_INSET_VAR); } function isKeyboardAvoidanceEnabled(): boolean { return ( PlatformDetector.isAndroid() && BrowserDetector.isFacebookInAppBrowser() ); }