import {
useEffect,
useMemo,
useRef,
useState,
useContext,
createContext,
useCallback,
Children,
ReactNode,
} from "react";
import {
Animated,
Dimensions,
View,
Text,
TouchableOpacity,
type ViewStyle,
type TextStyle,
} from "react-native";
import {
gameUIColors,
getSafeAreaInsets,
safeGetItem,
safeSetItem,
} from "@react-buoy/shared-ui";
import { DraggableHeader } from "./DraggableHeader";
import { useSafeAreaInsets } from "@react-buoy/shared-ui";
import { calculateTargetPosition } from "./dial/onboardingConstants";
import { MinimizedToolsStack } from "./MinimizedToolsStack";
import { useMinimizedTools, MinimizedTool } from "./MinimizedToolsContext";
import { useAppHost } from "./AppHost";
import { EnvironmentSelectorInline, type Environment } from "@react-buoy/shared-ui";
// Using Views to render grip dots; no react-native-svg dependency
// =============================
// Local Types (self-contained)
// =============================
export type UserRole = "admin" | "internal" | "user";
// =============================
// Icons (self-contained)
// =============================
/**
* Grip icon component for draggable areas
*
* Renders a vertical grip pattern using View components to avoid SVG dependencies.
* Creates two columns of three dots each with responsive sizing.
*
* @param props - Icon configuration
* @param props.size - Size of the icon in pixels (default: 24)
* @param props.color - Color of the grip dots (default: gameUIColors.secondary + "CC")
* @returns JSX.Element representing the grip icon
*/
function GripVerticalIcon({
size = 24,
color = gameUIColors.secondary + "CC",
}: {
size?: number;
color?: string;
}) {
const containerStyle: ViewStyle = {
width: size,
height: size,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
};
const dotSize = Math.max(2, Math.round(size / 6));
const columnGap = Math.max(2, Math.round(size / 12));
const rowGap = Math.max(2, Math.round(size / 12));
const columnStyle: ViewStyle = {
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginHorizontal: columnGap / 2,
};
const dotStyle: ViewStyle = {
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: color,
marginVertical: rowGap / 2,
};
return (
);
}
const STORAGE_KEYS = {
BUBBLE_POSITION_X: "@react_buoy_bubble_position_x",
BUBBLE_POSITION_Y: "@react_buoy_bubble_position_y",
} as const;
// Debug logging removed for production
// =============================
// Position persistence hook
// Extracted logic dedicated to state/IO
// =============================
/**
* Custom hook for managing floating tools position persistence
*
* Handles loading, saving, and validating the position of the floating tools bubble
* with automatic boundary checking and storage management.
*
* @param props - Configuration for position management
* @param props.animatedPosition - Animated.ValueXY for position updates
* @param props.bubbleWidth - Width of the bubble for boundary calculations
* @param props.bubbleHeight - Height of the bubble for boundary calculations
* @param props.enabled - Whether position persistence is enabled
* @param props.visibleHandleWidth - Width of visible handle when bubble is hidden
* @param props.listenersSuspended - Pause automatic listeners without disabling manual saves
*
* @returns Object containing position management functions
*
* @performance Uses debounced saving to avoid excessive storage operations
* @performance Validates positions against screen boundaries and safe areas
*/
function useFloatingToolsPosition({
animatedPosition,
bubbleWidth = 100,
bubbleHeight = 32,
enabled = true,
visibleHandleWidth = 32,
listenersSuspended = false,
}: {
animatedPosition: Animated.ValueXY;
bubbleWidth?: number;
bubbleHeight?: number;
enabled?: boolean;
visibleHandleWidth?: number;
listenersSuspended?: boolean;
}) {
const isInitialized = useRef(false);
const saveTimeoutRef = useRef | undefined>(
undefined
);
const savePosition = useCallback(
async (x: number, y: number) => {
if (!enabled) return;
try {
await Promise.all([
safeSetItem(STORAGE_KEYS.BUBBLE_POSITION_X, x.toString()),
safeSetItem(STORAGE_KEYS.BUBBLE_POSITION_Y, y.toString()),
]);
} catch (error) {
// Failed to save position - continue without persistence
}
},
[enabled]
);
const debouncedSavePosition = useCallback(
(x: number, y: number) => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => savePosition(x, y), 500);
},
[savePosition]
);
const loadPosition = useCallback(async (): Promise<{
x: number;
y: number;
} | null> => {
if (!enabled) return null;
try {
const [xStr, yStr] = await Promise.all([
safeGetItem(STORAGE_KEYS.BUBBLE_POSITION_X),
safeGetItem(STORAGE_KEYS.BUBBLE_POSITION_Y),
]);
if (xStr !== null && yStr !== null) {
const x = parseFloat(xStr);
const y = parseFloat(yStr);
if (!Number.isNaN(x) && !Number.isNaN(y)) return { x, y };
}
} catch (error) {
// Failed to load position - use default
}
return null;
}, [enabled]);
const validatePosition = useCallback(
(position: { x: number; y: number }) => {
const { width: screenWidth, height: screenHeight } =
Dimensions.get("window");
const safeArea = getSafeAreaInsets();
// Prevent going off left, top, and bottom edges with safe area
// Allow pushing off-screen to the right so only the grab handle remains visible
const minX = safeArea.left; // Respect safe area left
const maxX = screenWidth - visibleHandleWidth; // no right padding, ensure handle is visible
// Add small padding below the safe area top to ensure bubble doesn't go behind notch
const minY = safeArea.top + 20; // Ensure bubble is below safe area
const maxY = screenHeight - bubbleHeight - safeArea.bottom; // Respect safe area bottom
const clamped = {
x: Math.max(minX, Math.min(position.x, maxX)),
y: Math.max(minY, Math.min(position.y, maxY)),
} as const;
return clamped;
},
[visibleHandleWidth, bubbleHeight]
);
useEffect(() => {
if (!enabled || isInitialized.current) return;
const restore = async () => {
const saved = await loadPosition();
if (saved) {
const validated = validatePosition(saved);
// Check if the saved position is out of bounds
const wasOutOfBounds =
Math.abs(saved.x - validated.x) > 5 ||
Math.abs(saved.y - validated.y) > 5;
if (wasOutOfBounds) {
// Save the corrected position
await savePosition(validated.x, validated.y);
}
animatedPosition.setValue(validated);
} else {
const { width: screenWidth, height: screenHeight } =
Dimensions.get("window");
const safeArea = getSafeAreaInsets();
const defaultY = Math.max(
safeArea.top + 20,
Math.min(100, screenHeight - bubbleHeight - safeArea.bottom)
);
animatedPosition.setValue({
x: screenWidth - bubbleWidth - 20,
y: defaultY, // Ensure it's within safe area bounds
});
}
isInitialized.current = true;
};
restore();
}, [
enabled,
animatedPosition,
loadPosition,
validatePosition,
savePosition,
bubbleWidth,
bubbleHeight,
]);
useEffect(() => {
if (!enabled || !isInitialized.current || listenersSuspended) return;
const listener = animatedPosition.addListener((value) => {
debouncedSavePosition(value.x, value.y);
});
return () => {
animatedPosition.removeListener(listener);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
};
}, [enabled, listenersSuspended, animatedPosition, debouncedSavePosition]);
return {
savePosition,
loadPosition,
isInitialized: isInitialized.current,
} as const;
}
// =============================
// UI-only leaf components
// =============================
export function Divider() {
const dividerStyle: ViewStyle = {
width: 1,
height: 12,
backgroundColor: gameUIColors.muted + "66",
flexShrink: 0,
};
return ;
}
function getUserStatusConfig(userRole: UserRole) {
switch (userRole) {
case "admin":
return {
label: "Admin",
dotColor: gameUIColors.success,
textColor: gameUIColors.success,
};
case "internal":
return {
label: "Internal",
dotColor: gameUIColors.optional,
textColor: gameUIColors.optional,
};
case "user":
default:
return {
label: "User",
dotColor: gameUIColors.muted,
textColor: gameUIColors.secondary,
};
}
}
// Context to avoid brittle prop threading and keep API composable
const FloatingToolsContext = createContext<{ isDragging: boolean }>({
isDragging: false,
});
export function UserStatus({
userRole,
onPress,
}: {
userRole: UserRole;
onPress?: () => void;
}) {
const { isDragging } = useContext(FloatingToolsContext);
const config = getUserStatusConfig(userRole);
const containerStyle: ViewStyle = {
flexDirection: "row",
alignItems: "center",
paddingVertical: 6,
paddingHorizontal: 8,
flexShrink: 0,
};
const dotStyle: ViewStyle = {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: config.dotColor,
marginRight: 4,
};
const textStyle: TextStyle = {
fontSize: 10,
fontWeight: "500",
color: config.textColor,
letterSpacing: 0.3,
};
if (!onPress) {
return (
{config.label}
);
}
return (
{config.label}
);
}
// =============================
// Helpers
// =============================
function interleaveWithDividers(childrenArray: ReactNode[]): ReactNode[] {
const result: ReactNode[] = [];
childrenArray.forEach((child, index) => {
if (child == null || child === false) return;
result.push(child);
if (index < childrenArray.length - 1)
result.push();
});
return result;
}
// =============================
// Main Component (presentation only)
// =============================
export type FloatingToolsProps = {
enablePositionPersistence?: boolean;
children?: ReactNode;
/** When true, pushes the bubble to the side (hidden position) */
pushToSide?: boolean;
/** When true, centers the bubble on screen (for onboarding) */
centerOnboarding?: boolean;
/** Current environment for the environment selector */
environment?: Environment;
/** Available environments for switching */
availableEnvironments?: Environment[];
/** Callback when environment is selected */
onEnvironmentSwitch?: (env: Environment) => void;
/** Whether environment selector should be shown */
showEnvironmentSelector?: boolean;
};
/**
* FloatingTools - A draggable, resizable bubble for development tools
*
* This component provides a floating bubble interface that can contain various
* development tools and controls. It features:
* - Drag and drop positioning with boundary constraints
* - Hide/show functionality by dragging to screen edge
* - Position persistence across app restarts
* - Safe area aware positioning
* - Automatic divider insertion between child components
*
* @param props - Configuration for the floating tools
* @param props.enablePositionPersistence - Whether to save/restore position (default: true)
* @param props.children - Child components to render in the bubble
*
* @returns JSX.Element representing the floating tools bubble
*
* @example
* ```typescript
*
*
*
*
* ```
*
* @performance Uses native driver animations for smooth positioning
* @performance Implements efficient boundary checking and position validation
* @performance Includes debounced position saving for optimal storage performance
*/
export function FloatingTools({
enablePositionPersistence = true,
pushToSide = false,
centerOnboarding = false,
children,
environment,
availableEnvironments,
onEnvironmentSwitch,
showEnvironmentSelector = false,
}: FloatingToolsProps) {
// Animated position and drag state
const animatedPosition = useRef(new Animated.ValueXY()).current;
const saveTimeoutRef = useRef | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [bubbleSize, setBubbleSize] = useState({ width: 100, height: 32 });
const [isHidden, setIsHidden] = useState(false);
// Store the position before hiding to restore when showing
const savedPositionRef = useRef<{ x: number; y: number } | null>(null);
// Track if the hide state was triggered by pushToSide prop vs user toggle
const isPushedBySideRef = useRef(false);
// Track if user has explicitly chosen to show the menu (overriding pushToSide)
const userWantsVisibleRef = useRef(false);
// Track previous pushToSide value to detect transitions
const prevPushToSideRef = useRef(pushToSide);
const safeAreaInsets = useSafeAreaInsets();
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// Position persistence (state/IO extracted to hook)
const { savePosition } = useFloatingToolsPosition({
animatedPosition,
bubbleWidth: bubbleSize.width,
bubbleHeight: bubbleSize.height,
enabled: enablePositionPersistence,
visibleHandleWidth: 32,
listenersSuspended: isDragging,
});
// Effect to handle pushToSide prop changes
useEffect(() => {
// Reset user override when pushToSide becomes true (dial/modal opens)
// This allows auto-hide to work after user manually showed the menu
if (!prevPushToSideRef.current && pushToSide) {
userWantsVisibleRef.current = false;
}
prevPushToSideRef.current = pushToSide;
// Only push to side if:
// 1. pushToSide is true
// 2. Not currently hidden
// 3. Not being dragged
// 4. User hasn't manually restored (userWantsVisibleRef)
if (
pushToSide &&
!isHidden &&
!isDragging &&
!userWantsVisibleRef.current
) {
// Push to side
const currentX = (
animatedPosition.x as Animated.Value & { __getValue(): number }
).__getValue();
const currentY = (
animatedPosition.y as Animated.Value & { __getValue(): number }
).__getValue();
// Save current position
savedPositionRef.current = { x: currentX, y: currentY };
const hiddenX = screenWidth - 32; // Show only the grabber
isPushedBySideRef.current = true;
setIsHidden(true);
Animated.timing(animatedPosition, {
toValue: { x: hiddenX, y: currentY },
duration: 200,
useNativeDriver: false,
}).start(() => {
savePosition(hiddenX, currentY);
});
} else if (!pushToSide && isHidden && isPushedBySideRef.current) {
// Restore from side when pushToSide becomes false
if (savedPositionRef.current) {
const { x: targetX, y: targetY } = savedPositionRef.current;
isPushedBySideRef.current = false;
userWantsVisibleRef.current = false; // Reset user override when tools close
setIsHidden(false);
Animated.timing(animatedPosition, {
toValue: { x: targetX, y: targetY },
duration: 200,
useNativeDriver: false,
}).start(() => {
savePosition(targetX, targetY);
});
}
}
}, [
pushToSide,
isHidden,
isDragging,
screenWidth,
animatedPosition,
savePosition,
]);
// Check if bubble is in hidden position on load
useEffect(() => {
if (!enablePositionPersistence) return;
const checkHiddenState = () => {
const currentX = (
animatedPosition.x as Animated.Value & { __getValue(): number }
).__getValue();
// Check if bubble is at the hidden position (showing only grabber)
if (currentX >= screenWidth - 32 - 5) {
setIsHidden(true);
}
};
// Delay check to ensure position is loaded
const timer = setTimeout(checkHiddenState, 100);
return () => clearTimeout(timer);
}, [enablePositionPersistence, animatedPosition, screenWidth]);
// Default position when persistence disabled or during onboarding
useEffect(() => {
if (!enablePositionPersistence) {
if (centerOnboarding) {
// Center the bubble for onboarding - position under tooltip arrow
// Use shared calculation to ensure perfect alignment across all screens
const bottomOffset = calculateTargetPosition();
const centerX = (screenWidth - bubbleSize.width) / 2;
const centerY = screenHeight - bubbleSize.height - bottomOffset;
animatedPosition.setValue({
x: centerX,
y: centerY,
});
} else {
// Default right-side position
const defaultY = Math.max(
safeAreaInsets.top + 20,
Math.min(
100,
screenHeight - bubbleSize.height - safeAreaInsets.bottom
)
);
animatedPosition.setValue({
x: screenWidth - bubbleSize.width - 20,
y: defaultY,
});
}
}
}, [
enablePositionPersistence,
centerOnboarding,
animatedPosition,
bubbleSize.width,
bubbleSize.height,
safeAreaInsets.top,
safeAreaInsets.bottom,
screenWidth,
screenHeight,
]);
// Cleanup timeout on component unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
};
}, []);
// Toggle hide/show function
const toggleHideShow = useCallback(() => {
const currentX = (
animatedPosition.x as Animated.Value & { __getValue(): number }
).__getValue();
const currentY = (
animatedPosition.y as Animated.Value & { __getValue(): number }
).__getValue();
// Check if the menu is visually off-screen (more than half hidden to the right)
// This handles edge cases where isHidden state might be out of sync with actual position
const isVisuallyOffScreen = currentX > screenWidth - bubbleSize.width / 2;
if (isHidden || isVisuallyOffScreen) {
// Show the bubble - restore to saved position or default visible position
let targetX: number;
let targetY: number;
if (savedPositionRef.current && savedPositionRef.current.x < screenWidth - bubbleSize.width / 2) {
// Restore to the saved position only if it's a valid visible position
targetX = savedPositionRef.current.x;
targetY = savedPositionRef.current.y;
} else {
// Default visible position if no saved position or saved position is off-screen
targetX = screenWidth - bubbleSize.width - 20;
targetY = currentY;
}
// User explicitly wants the menu visible (overrides pushToSide)
isPushedBySideRef.current = false;
userWantsVisibleRef.current = true;
setIsHidden(false);
Animated.timing(animatedPosition, {
toValue: { x: targetX, y: targetY },
duration: 200,
useNativeDriver: false,
}).start(() => {
savePosition(targetX, targetY);
});
} else {
// Hide the bubble - save current position before hiding
savedPositionRef.current = { x: currentX, y: currentY };
// User explicitly wants the menu hidden
userWantsVisibleRef.current = false;
const hiddenX = screenWidth - 32; // Only show the 32px grabber
setIsHidden(true);
Animated.timing(animatedPosition, {
toValue: { x: hiddenX, y: currentY },
duration: 200,
useNativeDriver: false,
}).start(() => {
savePosition(hiddenX, currentY);
});
}
}, [animatedPosition, isHidden, bubbleSize.width, savePosition, screenWidth]);
const handleDragStart = useCallback(() => {
setIsDragging(true);
}, []);
const handleDragEnd = useCallback(
(finalPosition: { x: number; y: number }) => {
let { x: currentX, y: currentY } = finalPosition;
// Check if bubble is more than 50% over the right edge
const bubbleMidpoint = currentX + bubbleSize.width / 2;
const shouldHide = bubbleMidpoint > screenWidth;
if (shouldHide) {
// Animate to hidden position (only grabber visible)
const hiddenX = screenWidth - 32; // Only show the 32px grabber
setIsHidden(true);
Animated.timing(animatedPosition, {
toValue: { x: hiddenX, y: currentY },
duration: 200,
useNativeDriver: false,
}).start(() => {
savePosition(hiddenX, currentY);
});
} else {
// Check if we're in hidden state and user is pulling it back
if (isHidden && currentX < screenWidth - 32 - 10) {
setIsHidden(false);
}
// Update saved position if bubble is in visible area (not hidden)
if (currentX < screenWidth - bubbleSize.width / 2) {
savedPositionRef.current = { x: currentX, y: currentY };
}
savePosition(currentX, currentY);
}
setIsDragging(false);
},
[animatedPosition, bubbleSize.width, isHidden, savePosition, screenWidth]
);
// Stable styles
const bubbleStyle: Animated.WithAnimatedObject = useMemo(
() => ({
position: "absolute",
zIndex: 1001,
transform: animatedPosition.getTranslateTransform(),
}),
[animatedPosition]
);
// Get minimized tools context and app host for restoration
const { minimizedTools } = useMinimizedTools();
const { restore: restoreInAppHost } = useAppHost();
// Check if minimized tools are showing (for seamless connection)
const hasMinimizedTools = minimizedTools.length > 0;
const containerStyle: ViewStyle = {
flexDirection: "row",
alignItems: "center",
backgroundColor: gameUIColors.panel,
// When minimized tools showing, remove top-left radius so they connect seamlessly
borderTopLeftRadius: hasMinimizedTools ? 0 : 6,
borderTopRightRadius: 6,
borderBottomLeftRadius: 6,
borderBottomRightRadius: 6,
// Use individual border widths when minimized tools are showing
borderLeftWidth: isDragging ? 2 : 1,
borderRightWidth: isDragging ? 2 : 1,
borderBottomWidth: isDragging ? 2 : 1,
borderTopWidth: hasMinimizedTools ? 0 : (isDragging ? 2 : 1),
borderColor: isDragging ? gameUIColors.info : gameUIColors.muted + "66",
overflow: "visible",
elevation: 8,
shadowColor: isDragging ? gameUIColors.info + "99" : "#000",
shadowOffset: { width: 0, height: isDragging ? 6 : 4 },
shadowOpacity: isDragging ? 0.6 : 0.3,
shadowRadius: isDragging ? 12 : 8,
};
const dragHandleStyle: ViewStyle = {
paddingHorizontal: 6,
paddingVertical: 6,
backgroundColor: gameUIColors.muted + "1A",
alignItems: "center",
justifyContent: "center",
width: 32,
borderRightWidth: 1,
borderRightColor: gameUIColors.muted + "66",
// Remove top-left radius when minimized tools are showing
borderTopLeftRadius: hasMinimizedTools ? 0 : undefined,
};
const contentStyle: ViewStyle = {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingRight: 8,
};
// Compose actions row with automatic dividers
const actions = useMemo(
() => interleaveWithDividers(Children.toArray(children)),
[children]
);
// Handle restore from minimized tools stack
const handleMinimizedRestore = useCallback(
(tool: MinimizedTool) => {
restoreInAppHost(tool.instanceId, tool.modalState);
},
[restoreInAppHost]
);
// Width for the minimized tools stack - match the drag handle width
const minimizedStackWidth = 32;
return (
{/* Outer wrapper to allow overflow for minimized tools */}
{/* Minimized tools stack - positioned above the main bubble */}
{minimizedTools.length > 0 && (
)}
{/* Main floating tools bubble */}
{
const { width, height } = event.nativeEvent.layout;
setBubbleSize({ width, height });
}}
>
{/* Environment selector in the row - expands upward */}
{showEnvironmentSelector &&
environment &&
availableEnvironments &&
onEnvironmentSwitch && (
)}
{actions}
);
}