254 lines
8.0 KiB
TypeScript
254 lines
8.0 KiB
TypeScript
"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<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" || !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()
|
|
);
|
|
}
|