import { createContext, use, useEffect, useState, type ReactNode } from "react"; import { Platform } from "react-native"; import { activeAppWindowId, useAppWindowId, type AppWindowId } from "../lib/appWindows"; import { deriveDeviceType, isDuoDevice, type DeviceType } from "../lib/device-type"; import type { NativeLayoutMetrics } from "../lib/reserved-regions"; import { NativeLayoutObserver } from "./NativeLayoutObserver"; const IS_PAD = Platform.OS === "ios" && Platform.isPad; const NativeLayoutContext = createContext(null); // Separate from the metrics so device checks skip every resize and safe-area update. const DeviceTypeContext = createContext( deriveDeviceType({ isPad: IS_PAD, metrics: null }), ); // Binaries without the native no-hinge fallback never report metrics on ordinary // iPhones running iOS 27.1 or later; consumers stop waiting at this deadline. export const NATIVE_METRICS_TIMEOUT_MS = 1000; const NativeLayoutMetricsDeadlineContext = createContext(0); // Column reservations come from UIKit's layout, outside the resizing navigator. export const NativeColumnLayoutMetricsContext = createContext(null); // Each window's latest metrics outside React, for decisions made before the first screen renders. const latestMetrics = new Map(); const metricsWaiters = new Set<(metrics: NativeLayoutMetrics) => void>(); /** Resolves with the first native layout metrics, or null after `timeoutMs` without any. */ export function firstNativeLayoutMetrics(timeoutMs: number) { const latest = currentNativeLayoutMetrics(); if (latest) return Promise.resolve(latest); return new Promise((resolve) => { const settle = (metrics: NativeLayoutMetrics | null) => { metricsWaiters.delete(settle); clearTimeout(timeout); resolve(metrics); }; const timeout = setTimeout(() => settle(null), timeoutMs); metricsWaiters.add(settle); }); } /** The metrics of the window in use. */ export function currentNativeLayoutMetrics() { const active = activeAppWindowId(); return ( (active === null ? undefined : latestMetrics.get(active)) ?? [...latestMetrics.values()].at(-1) ?? null ); } export function NativeLayoutMetricsProvider(props: { readonly children: ReactNode }) { const windowId = useAppWindowId(); const [metrics, setMetrics] = useState(null); const [metricsDeadline] = useState(() => Date.now() + NATIVE_METRICS_TIMEOUT_MS); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- drops a closed window's metrics from the store read outside React useEffect( () => () => { latestMetrics.delete(windowId); }, [windowId], ); const publish = (next: NativeLayoutMetrics) => { latestMetrics.set(windowId, next); for (const settle of metricsWaiters) settle(next); setMetrics(next); }; const deviceType = deriveDeviceType({ isPad: IS_PAD, metrics }); return ( {props.children} ); } export function useNativeLayoutMetrics() { return use(NativeLayoutContext); } /** Epoch ms after which a consumer still waiting for the first metrics gives up. */ export function useNativeLayoutMetricsDeadline() { return use(NativeLayoutMetricsDeadlineContext); } export function useNativeColumnLayoutMetrics() { return use(NativeColumnLayoutMetricsContext); } export function useDeviceType() { return use(DeviceTypeContext); } /** * Where the Duo's floating bottom controls end in this column. Its tab bar floats below the safe * area, and the controls beside it sit on the same line. */ export function useDuoBottomBarInset() { const deviceType = useDeviceType(); const metrics = use(NativeColumnLayoutMetricsContext); // Sheets have no rail to align with and can report zero for the bar region. // Their controls must keep using the sheet's bottom safe area. const inset = metrics?.bottomBarInset; return isDuoDevice(deviceType) && inset !== undefined && inset > 0 ? inset : undefined; }