1import { type ReactNode, useEffect, useRef, useState } from "react";
2import { View } from "react-native";
3
4// Minimal in-tree portal for Android overlays. AndroidAnchoredMenu projects
5// its dropdown here instead of into an RN Modal: a Modal is a separate native
6// window, so presenting one moves window focus and closes the soft keyboard —
7// which matters for menus anchored to the keyboard-sticky composer pills.
8type Entries = ReadonlyMap<number, ReactNode>;
9type Listener = (entries: Entries) => void;
10
11let nextKey = 0;
12const entries = new Map<number, ReactNode>();
13const listeners = new Set<Listener>();
14
15function emit() {
16 const snapshot = new Map(entries);
17 for (const listener of listeners) {
18 listener(snapshot);
19 }
20}
21
22/** Projects children into the app-root OverlayPortalHost. */
23export function OverlayPortal(props: { readonly children: ReactNode }) {
24 const keyRef = useRef<number | null>(null);
25 keyRef.current ??= nextKey++;
26 const key = keyRef.current;
27
28 // No dependency array: re-project after every render so the host always
29 // shows the current content (menus re-render while open — drill-in, theme).
30 useEffect(() => {
31 entries.set(key, props.children);
32 emit();
33 });
34
35 useEffect(
36 () => () => {
37 entries.delete(key);
38 emit();
39 },
40 [key],
41 );
42
43 return null;
44}
45
46/** Mounted once at the app root, above the navigation container. */
47export function OverlayPortalHost() {
48 const [current, setCurrent] = useState<Entries>(() => new Map());
49
50 useEffect(() => {
51 listeners.add(setCurrent);
52 return () => {
53 listeners.delete(setCurrent);
54 };
55 }, []);
56
57 if (current.size === 0) {
58 return null;
59 }
60 return (
61 <View pointerEvents="box-none" className="absolute inset-0">
62 {[...current.entries()].map(([key, node]) => (
63 <View key={key} pointerEvents="box-none" className="absolute inset-0">
64 {node}
65 </View>
66 ))}
67 </View>
68 );
69}