diff --git a/js/mobile/src/components/BottomSheet.tsx b/js/mobile/src/components/BottomSheet.tsx index b6ec381..b0ae060 100644 --- a/js/mobile/src/components/BottomSheet.tsx +++ b/js/mobile/src/components/BottomSheet.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Dimensions, KeyboardAvoidingView, @@ -30,6 +30,13 @@ const ANIMATION_MS = 240; interface BottomSheetProps { open: boolean; onClose: () => void; + /** + * Fires after the close animation completes and the underlying Modal has + * unmounted. Use this to chain a follow-up sheet/Alert without stacking + * iOS Modals — presenting a second Modal while another is still mounted + * silently fails on iOS and leaves the app looking frozen. + */ + onClosed?: () => void; /** When true, wrap content in KeyboardAvoidingView so the sheet floats above the keyboard. */ avoidKeyboard?: boolean; /** @@ -49,6 +56,7 @@ interface BottomSheetProps { export function BottomSheet({ open, onClose, + onClosed, avoidKeyboard = false, maxHeight = "85%", children, @@ -58,6 +66,18 @@ export function BottomSheet({ const [mounted, setMounted] = useState(false); const translateY = useSharedValue(SCREEN_HEIGHT); + // Latest onClosed in a ref so the worklet→JS bridge always invokes the + // current callback even if the parent re-rendered with a new closure. + const onClosedRef = useRef(onClosed); + useEffect(() => { + onClosedRef.current = onClosed; + }, [onClosed]); + + const handleClosed = useCallback(() => { + setMounted(false); + onClosedRef.current?.(); + }, []); + useEffect(() => { if (open) { setMounted(true); @@ -73,7 +93,7 @@ export function BottomSheet({ SCREEN_HEIGHT, { duration: ANIMATION_MS, easing: Easing.in(Easing.cubic) }, (finished) => { - if (finished) runOnJS(setMounted)(false); + if (finished) runOnJS(handleClosed)(); }, ); } diff --git a/js/mobile/src/features/stream-view/StreamActionsSheet.tsx b/js/mobile/src/features/stream-view/StreamActionsSheet.tsx index 84d083a..ddb2a2d 100644 --- a/js/mobile/src/features/stream-view/StreamActionsSheet.tsx +++ b/js/mobile/src/features/stream-view/StreamActionsSheet.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Pressable, Text, View } from "react-native"; import { CircleCheckBig, @@ -33,13 +34,28 @@ export function StreamActionsSheet({ isCreator, canDeleteParticle, }: StreamActionsSheetProps) { + // Hold the picked action until the sheet's Modal has fully unmounted, then + // dispatch. Follow-ups like rename/members open another Modal and "delete + // particle" shows an Alert — both are iOS Modals, and iOS will not present + // a second Modal while another is still on screen. Without this defer, the + // tap appears to do nothing and the app feels frozen behind a phantom + // overlay until the close animation finishes. + const [pending, setPending] = useState(null); + const choose = (id: StreamActionId) => { + setPending(id); onClose(); - onSelect(id); + }; + + const handleClosed = () => { + if (pending) { + onSelect(pending); + setPending(null); + } }; return ( - +