diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 53998ac58c..6e0aa643d7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,9 @@ "allow": [ "Bash(yarn typecheck *)", "Bash(yarn lint *)", - "Bash(yarn test *)" + "Bash(yarn test *)", + "mcp__plugin_claude-mem_mcp-search__search", + "mcp__plugin_claude-mem_mcp-search__get_observations" ] } } diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 1566f09736..0334608c3f 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -23,7 +23,7 @@ import {type Dimensions} from '#/lib/media/types' import {mergeRefs} from '#/lib/merge-refs' import {useA11y} from '#/state/a11y' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' -import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import {DrawerWaitGesture} from '#/view/shell/DrawerWaitGesture' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' import {AutoSizedImage} from '#/components/images/AutoSizedImage' @@ -248,7 +248,7 @@ export function Gallery({ }, ]} onLayout={measure}> - + - + ) } diff --git a/src/view/shell/DrawerWaitGesture.ios.tsx b/src/view/shell/DrawerWaitGesture.ios.tsx new file mode 100644 index 0000000000..85b9775bac --- /dev/null +++ b/src/view/shell/DrawerWaitGesture.ios.tsx @@ -0,0 +1,6 @@ +/* + * iOS keeps the previously-shipping BlockDrawerGesture wrapper, which works + * correctly here. Only Android needs the new drawer-side wait mechanism. + * See APP-2119. + */ +export {BlockDrawerGesture as DrawerWaitGesture} from './BlockDrawerGesture' diff --git a/src/view/shell/DrawerWaitGesture.tsx b/src/view/shell/DrawerWaitGesture.tsx new file mode 100644 index 0000000000..53210fbe38 --- /dev/null +++ b/src/view/shell/DrawerWaitGesture.tsx @@ -0,0 +1,14 @@ +import {GestureDetector} from 'react-native-gesture-handler' + +import {useRegisterDrawerWaitGesture} from './DrawerWaitGestureContext' + +/* + * Registers a Gesture.Native() with the shell drawer so the drawer's pan + * gesture must wait for it to fail before activating. Wrap a horizontal-swipe + * surface (like the image carousel) where the drawer would otherwise race + * the inner scroll on Android. See APP-2119. + */ +export function DrawerWaitGesture({children}: {children: React.ReactNode}) { + const gesture = useRegisterDrawerWaitGesture() + return {children} +} diff --git a/src/view/shell/DrawerWaitGesture.web.tsx b/src/view/shell/DrawerWaitGesture.web.tsx new file mode 100644 index 0000000000..f145199260 --- /dev/null +++ b/src/view/shell/DrawerWaitGesture.web.tsx @@ -0,0 +1,3 @@ +export function DrawerWaitGesture({children}: {children: React.ReactNode}) { + return children +} diff --git a/src/view/shell/DrawerWaitGestureContext.tsx b/src/view/shell/DrawerWaitGestureContext.tsx new file mode 100644 index 0000000000..3012a44c6e --- /dev/null +++ b/src/view/shell/DrawerWaitGestureContext.tsx @@ -0,0 +1,66 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import {Gesture, type NativeGesture} from 'react-native-gesture-handler' + +/* + * Lets descendant components register a Gesture.Native() that the shell + * drawer's pan handler must wait to fail before it can activate. Used to + * prevent the drawer from intercepting horizontal swipes inside surfaces + * like the image carousel where the child-side blocksExternalGesture pattern + * is unreliable on Android. See APP-2119. + * + * Two contexts so consumers re-render only when they need to: + * - RegisterContext is referentially stable, only used by the inner hook + * that registers a single gesture. Galleries don't re-render when their + * peers mount/unmount. + * - GesturesContext is reactive; only DrawerLayout reads it. + */ + +type Register = (gesture: NativeGesture) => () => void + +const RegisterContext = createContext(null) +const GesturesContext = createContext([]) + +export function DrawerWaitGestureProvider({ + children, +}: { + children: React.ReactNode +}) { + const [gestures, setGestures] = useState([]) + + const register = useCallback(gesture => { + setGestures(prev => [...prev, gesture]) + return () => { + setGestures(prev => prev.filter(g => g !== gesture)) + } + }, []) + + return ( + + + {children} + + + ) +} + +export function useDrawerWaitGestures(): readonly NativeGesture[] { + return useContext(GesturesContext) +} + +export function useRegisterDrawerWaitGesture(): NativeGesture { + const register = useContext(RegisterContext) + const [gesture] = useState(() => Gesture.Native()) + + useEffect(() => { + if (!register) return + return register(gesture) + }, [register, gesture]) + + return gesture +} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 53c716d222..3fae7d12a4 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -1,8 +1,7 @@ -import {useCallback, useEffect, useState} from 'react' +import {useCallback, useEffect} from 'react' import {BackHandler, useWindowDimensions, View} from 'react-native' import {Drawer} from 'react-native-drawer-layout' import {SystemBars} from 'react-native-edge-to-edge' -import {Gesture} from 'react-native-gesture-handler' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useNavigation, useNavigationState} from '@react-navigation/native' @@ -49,6 +48,10 @@ import {BottomSheetOutlet} from '../../../modules/bottom-sheet' import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView' import {Composer} from './Composer' import {DrawerContent} from './Drawer' +import { + DrawerWaitGestureProvider, + useDrawerWaitGestures, +} from './DrawerWaitGestureContext' function ShellInner() { const winDim = useWindowDimensions() @@ -105,7 +108,9 @@ function ShellInner() { - + + + @@ -145,7 +150,7 @@ function DrawerLayout({children}: {children: React.ReactNode}) { const {hasSession} = useSession() const swipeEnabled = !canGoBack && hasSession && !isDrawerSwipeDisabled - const [trendingScrollGesture] = useState(() => Gesture.Native()) + const waitGestures = useDrawerWaitGestures() const renderDrawerContent = useCallback(() => , []) const onOpenDrawer = useCallback( @@ -162,7 +167,9 @@ function DrawerLayout({children}: {children: React.ReactNode}) { renderDrawerContent={renderDrawerContent} drawerStyle={{width: Math.min(400, winDim.width * 0.8)}} configureGestureHandler={handler => { - handler = handler.requireExternalGestureToFail(trendingScrollGesture) + for (const gesture of waitGestures) { + handler = handler.requireExternalGestureToFail(gesture) + } if (swipeEnabled) { if (isDrawerOpen) {