import { forwardRef, useCallback, useEffect, useMemo } from "react"; import type { ReactNode, Ref } from "react"; import { createPortal } from "react-dom"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import type { TextInputProps } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; import { BottomSheetBackdrop, BottomSheetScrollView, BottomSheetTextInput, type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; import { FileDropZone } from "@/components/file-drop-zone"; import type { ImageAttachment } from "@/components/message-input"; import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, } from "@/components/ui/isolated-bottom-sheet-modal"; import { isWeb } from "@/constants/platform"; type EscHandler = () => void; const escStack: EscHandler[] = []; let escListenerAttached = false; const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; function handleEscKeyDown(event: KeyboardEvent) { if (event.key !== "Escape") return; const top = escStack[escStack.length - 1]; if (!top) return; event.stopPropagation(); event.preventDefault(); top(); } function pushEscHandler(handler: EscHandler): () => void { escStack.push(handler); if (!escListenerAttached && typeof window !== "undefined") { window.addEventListener("keydown", handleEscKeyDown, true); escListenerAttached = true; } return () => { const index = escStack.lastIndexOf(handler); if (index !== -1) escStack.splice(index, 1); if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") { window.removeEventListener("keydown", handleEscKeyDown, true); escListenerAttached = false; } }; } const styles = StyleSheet.create((theme) => ({ desktopOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0,0,0,0.55)", justifyContent: "center", alignItems: "center", padding: theme.spacing[6], zIndex: OVERLAY_Z.modal, pointerEvents: "auto" as const, }, desktopCard: { width: "100%", maxWidth: 520, maxHeight: "85%", flexShrink: 1, minHeight: 0, backgroundColor: theme.colors.surface1, borderRadius: theme.borderRadius.xl, borderWidth: 1, borderColor: theme.colors.surface2, }, header: { paddingHorizontal: theme.spacing[6], paddingVertical: theme.spacing[4], flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", borderBottomWidth: 1, borderBottomColor: theme.colors.surface2, gap: theme.spacing[3], }, headerTitleGroup: { flex: 1, gap: theme.spacing[2], minWidth: 0, }, title: { flex: 1, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, }, headerActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], marginLeft: theme.spacing[3], marginRight: theme.spacing[2], }, closeButton: { padding: theme.spacing[2], borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface2, }, desktopScroll: { flexShrink: 1, minHeight: 0, }, desktopContent: { padding: theme.spacing[6], gap: theme.spacing[4], flexGrow: 1, }, bottomSheetHeader: { paddingHorizontal: theme.spacing[6], paddingTop: theme.spacing[4], paddingBottom: theme.spacing[3], flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", borderBottomWidth: 1, borderBottomColor: theme.colors.surface2, gap: theme.spacing[3], }, bottomSheetContent: { padding: theme.spacing[6], gap: theme.spacing[4], }, bottomSheetStaticContent: { flex: 1, padding: theme.spacing[6], gap: theme.spacing[4], minHeight: 0, }, desktopStaticContent: { flexShrink: 1, minHeight: 0, padding: theme.spacing[6], gap: theme.spacing[4], }, })); function SheetBackground({ style }: BottomSheetBackgroundProps) { const { theme } = useUnistyles(); const combinedStyle = useMemo( () => [ style, { backgroundColor: theme.colors.surface1, borderTopLeftRadius: theme.borderRadius.xl, borderTopRightRadius: theme.borderRadius.xl, }, ], [style, theme.colors.surface1, theme.borderRadius.xl], ); return ; } export interface AdaptiveModalSheetProps { title: string; /** Optional content rendered below the title in the header area. */ subtitle?: ReactNode; visible: boolean; onClose: () => void; children: ReactNode; headerActions?: ReactNode; snapPoints?: string[]; testID?: string; /** Override the max width of the desktop card. */ desktopMaxWidth?: number; /** When provided, wraps the card content in a FileDropZone. */ onFilesDropped?: (files: ImageAttachment[]) => void; scrollable?: boolean; } export function AdaptiveModalSheet({ title, subtitle, visible, onClose, children, headerActions, snapPoints, testID, desktopMaxWidth, onFilesDropped, scrollable = true, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); const titleColor = theme.colors.foreground; const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const handleIndicatorStyle = useMemo( () => ({ backgroundColor: theme.colors.surface2 }), [theme.colors.surface2], ); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible, isEnabled: isMobile, onClose, }); const renderBackdrop = useCallback( (props: React.ComponentProps) => ( ), [], ); const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]); const desktopCardStyle = useMemo( () => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }], [desktopMaxWidth], ); useEffect(() => { if (!isWeb || isMobile || !visible) return; return pushEscHandler(onClose); }, [visible, isMobile, onClose]); if (isMobile) { return ( {title} {subtitle} {headerActions ? {headerActions} : null} {scrollable ? ( {children} ) : ( {children} )} ); } const cardInner = ( <> {title} {subtitle} {headerActions ? {headerActions} : null} {scrollable ? ( {children} ) : ( {children} )} ); const desktopContent = ( {onFilesDropped ? ( {cardInner} ) : ( cardInner )} ); // On web, use portal to overlay root for consistent stacking with toasts if (isWeb && typeof document !== "undefined") { if (!visible) return null; return createPortal(desktopContent, getOverlayRoot()); } return ( {desktopContent} ); } /** * TextInput that automatically uses BottomSheetTextInput on mobile * for proper keyboard dodging in AdaptiveModalSheet. */ export const AdaptiveTextInput = forwardRef( function AdaptiveTextInput(props, ref) { const isMobile = useIsCompactFormFactor(); if (isMobile) { return } {...props} />; } return ; }, );