import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TouchableOpacity, StyleSheet, View, Text, Dimensions, Animated, } from "react-native"; import { FloatingTools, UserRole, UserStatus } from "./floatingTools"; import type { InstalledApp, FloatingMenuActions, FloatingMenuState, } from "./types"; import type { DefaultFloatingConfig, DefaultDialConfig } from "./defaultConfig"; import { DialDevTools } from "./dial/DialDevTools"; import type { Environment } from "@react-buoy/shared-ui"; import { EnvironmentIndicator, gameUIColors, safeGetItem, safeSetItem, useHintsDisabled, } from "@react-buoy/shared-ui"; import { useDevToolsSettings } from "./DevToolsSettingsModal"; import { useAppHost } from "./AppHost"; import { useDevToolsVisibility } from "./DevToolsVisibilityContext"; import { toggleStateManager } from "./ToggleStateManager"; import { OnboardingTooltip } from "./dial/OnboardingTooltip"; /** * Props for the floating developer tools launcher. Controls which apps are shown and * how the menu integrates with the current host environment. */ export interface FloatingMenuProps { /** Dev tool apps that can be opened from the floating menu. */ apps: InstalledApp[]; /** Shared state object passed to app renderers (e.g. icons) and the dial. */ state?: FloatingMenuState; /** Shared action callbacks exposed to app renderers for interacting with the menu. */ actions?: FloatingMenuActions; /** When true, hides the floating row (used when another dev app takes focus). */ hidden?: boolean; /** Active environment metadata displayed via the environment indicator, if enabled. */ environment?: Environment; /** Optional role that determines which user status badge is rendered. */ userRole?: UserRole; /** Default tools to enable in the floating bubble when no user settings exist. */ defaultFloatingTools?: DefaultFloatingConfig; /** Default tools to enable in the dial menu when no user settings exist (max 6). */ defaultDialTools?: DefaultDialConfig; /** * List of environments available for switching. * When provided along with onEnvironmentSwitch, the environment badge becomes interactive. */ availableEnvironments?: Environment[]; /** * Callback fired when the user selects an environment from the switcher. * The app is responsible for actually switching environments and updating the `environment` prop. */ onEnvironmentSwitch?: (environment: Environment) => void; } /** * FloatingMenu renders the persistent developer tools entry point. It handles visibility, * integrates with the AppHost, and presents available tools as floating shortcuts and a dial. */ const FLOATING_MENU_ONBOARDING_KEY = "@react_buoy_floating_menu_tooltip_shown"; const ONBOARDING_STEP_KEY = "@react_buoy_onboarding_step"; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); type OnboardingStep = "positioning" | "opening" | "complete"; export const FloatingMenu: FC = ({ apps, state, actions, hidden, environment, userRole, availableEnvironments, onEnvironmentSwitch, }) => { const [internalHidden, setInternalHidden] = useState(false); const [showDial, setShowDial] = useState(false); const [onboardingStep, setOnboardingStep] = useState( null ); const [, forceUpdate] = useState(0); // Used to force re-render when toggle states change const onboardingDismissedRef = useRef(false); // Track if onboarding was dismissed const hintsDisabled = useHintsDisabled(); // Determine if environment selector should be shown const showEnvironmentSelector = Boolean( availableEnvironments?.length && onEnvironmentSwitch ); const { isAnyOpen, open, registerApps } = useAppHost(); const wasAppOpenRef = useRef(isAnyOpen); const { setDialOpen, setToolOpen } = useDevToolsVisibility(); // Check onboarding status on first load useEffect(() => { // Skip onboarding if hints are disabled if (hintsDisabled) { return; } const checkOnboarding = async () => { try { const hasSeenOnboarding = await safeGetItem( FLOATING_MENU_ONBOARDING_KEY ); if (!hasSeenOnboarding) { // Small delay to let the UI settle before showing tooltip setTimeout(() => { setOnboardingStep("positioning"); }, 1000); } } catch (error) { // If there's an error reading storage, don't show onboarding } }; checkOnboarding(); }, [hintsDisabled]); // Subscribe to toggle state changes to update icon colors useEffect(() => { const unsubscribe = toggleStateManager.subscribe(() => { forceUpdate((prev) => prev + 1); }); return unsubscribe; }, []); // Sync dial state with visibility context useEffect(() => { setDialOpen(showDial); }, [showDial, setDialOpen]); // Sync tool open state with visibility context useEffect(() => { setToolOpen(isAnyOpen); }, [isAnyOpen, setToolOpen]); // When an app is open or dial is shown, push the menu to the side instead of hiding completely const shouldPushToSide = useMemo( () => Boolean(showDial || isAnyOpen), [showDial, isAnyOpen] ); // Use external hidden prop or internal hidden state for complete hiding const isCompletelyHidden = useMemo( () => Boolean(hidden ?? internalHidden), [hidden, internalHidden] ); const { settings: devToolsSettings } = useDevToolsSettings(); // Register apps with AppHost for persistence useEffect(() => { if (registerApps) { registerApps(apps); } }, [apps, registerApps]); const mergedActions = useMemo(() => { return { ...(actions ?? {}), closeMenu: () => setShowDial(false), hideFloatingRow: () => setInternalHidden(true), showFloatingRow: () => setInternalHidden(false), notifyToggleChange: () => forceUpdate((prev) => prev + 1), } as FloatingMenuActions; }, [actions]); useEffect(() => { if (wasAppOpenRef.current && !isAnyOpen) { setInternalHidden(false); setShowDial(false); } wasAppOpenRef.current = isAnyOpen; }, [isAnyOpen]); // Filter function for floating tools based on settings const isFloatingEnabled = (id: string) => { if (!devToolsSettings) return false; // Default to disabled for tools without explicit preferences return devToolsSettings.floatingTools[id] ?? false; }; // Dial is the default/only layout const handlePress = (app: InstalledApp) => { // Call the app's onPress callback if provided, passing actions for toggle tools app?.onPress?.(mergedActions); // Only open modal if not a toggle-only tool if (app.launchMode !== "toggle-only") { // Resolve the icon for minimize stack display // IMPORTANT: Use React.createElement for function components to preserve hooks const resolvedIcon = typeof app.icon === "function" ? React.createElement(app.icon, { slot: "dial", size: 20 }) : app.icon; open({ id: app.id, title: app.name, component: app.component, props: app.props, launchMode: app.launchMode ?? "self-modal", singleton: app.singleton ?? true, icon: resolvedIcon, color: app.color, }); } }; const handleOnboardingDismiss = () => { // Mark as dismissed immediately in ref (synchronous, no re-render needed) onboardingDismissedRef.current = true; // Update state to hide tooltip setOnboardingStep(null); // Save to storage asynchronously in the background safeSetItem(FLOATING_MENU_ONBOARDING_KEY, "true").catch((error) => { // Silently fail - user already saw onboarding, just won't persist console.warn("Failed to save onboarding state:", error); }); }; const handleDialOpen = () => { // If user opens dial during onboarding, mark onboarding as complete if (isOnboarding) { handleOnboardingDismiss(); } setShowDial(true); }; // Determine if we're in onboarding mode (only when explicitly set to positioning AND not dismissed AND hints not disabled) const isOnboarding = onboardingStep === "positioning" && !onboardingDismissedRef.current && !hintsDisabled; // During onboarding, disable position persistence and use centered position const shouldEnablePositionPersistence = !isOnboarding; return ( <> {/* Onboarding Tooltips - Render outside and before floating menu for proper z-index */} {isOnboarding && !showDial && !isAnyOpen && ( {/* Dark backdrop */} {/* Single onboarding tooltip */} )} {/* Environment badge (if enabled in settings and no selector) */} {devToolsSettings?.floatingTools?.environment && environment && !showEnvironmentSelector ? ( ) : null} {/* Preferred: UserStatus as the dial launcher when a userRole is provided */} {userRole ? ( ) : ( // Fallback: small launcher icon to ensure settings are always accessible )} {apps .filter( (a) => (a.slot ?? "both") !== "dial" && isFloatingEnabled(a.id) ) .map((app) => ( handlePress(app)} style={styles.fab} > {/* * ⚠️ IMPORTANT - DO NOT CHANGE THIS RENDERING PATTERN ⚠️ * Icons MUST be rendered as JSX components (), * NOT called as functions (app.icon({...})). * * This allows icon components to use React hooks (useState, useEffect) * for subscribing to state changes (e.g., WiFi toggle subscribing to onlineManager). * * If you change this to call icons as functions, hooks will break and * dynamic icon updates (like WiFi color changing) will stop working. */} {(() => { if (typeof app.icon === "function") { const IconComponent = app.icon; return ( ); } return app.icon; })()} ))} {showDial && ( { setShowDial(false); }} /> )} ); }; const styles = StyleSheet.create({ fab: { paddingHorizontal: 6, paddingVertical: 4, borderRadius: 6, marginRight: 4, alignItems: "center", justifyContent: "center", minWidth: 0, minHeight: 0, backgroundColor: "transparent", }, menuButton: { paddingHorizontal: 4, paddingVertical: 2, minWidth: 16, alignItems: "center", justifyContent: "center", }, menuDots: { color: "#8CA2C8", fontSize: 14, fontWeight: "900", }, onboardingContainer: { ...StyleSheet.absoluteFillObject, zIndex: 10000, }, onboardingBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.85)", }, }); interface MenuLauncherIconProps { /** Pixel width/height of the square icon. */ size?: number; /** Custom color applied to each dot. */ color?: string; } /** Minimal 3x3 dot icon used when the user status badge is unavailable. */ const MenuLauncherIcon: FC = ({ size = 14, color = gameUIColors.info, }) => { const dotSize = Math.max(2, Math.floor(size / 4)); const gap = Math.max(1, Math.floor(size / 16)); const items = Array.from({ length: 9 }); return ( {items.map((_, i) => ( ))} ); };