diff --git a/mobile_app/app/(tabs)/_layout.tsx b/mobile_app/app/(tabs)/_layout.tsx index 3f3f44b..1d2183b 100644 --- a/mobile_app/app/(tabs)/_layout.tsx +++ b/mobile_app/app/(tabs)/_layout.tsx @@ -24,6 +24,8 @@ import Reanimated, { import { Feather } from '@expo/vector-icons'; import { fontFamily, fontSize, radii, spacing, useTheme } from '@/theme'; import { subscribeDrawer } from '@/hooks/drawerState'; +import { closeThreadRef } from '@/hooks/closeThread'; +import { messagesFocusedRef } from '@/hooks/messagesFocused'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { appMotion } from '@/src/design-system/motion'; import * as haptics from '@/src/design-system/haptics'; @@ -341,6 +343,13 @@ export default function TabLayout() { useEffect(() => { if (Platform.OS !== 'android') return; const sub = BackHandler.addEventListener('hardwareBackPress', () => { + // An open chat thread is MessagesScreen state, not a route — back must + // close it first. Gate on focus so a thread left open on the (frozen) + // Messages tab doesn't swallow back presses made from other tabs. + if (messagesFocusedRef.current && closeThreadRef.current) { + closeThreadRef.current(); + return true; + } if (exitWindowRef.current) { BackHandler.exitApp(); return true; diff --git a/mobile_app/app/onboarding.tsx b/mobile_app/app/onboarding.tsx index c067825..827b1e3 100644 --- a/mobile_app/app/onboarding.tsx +++ b/mobile_app/app/onboarding.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, Animated, Image, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { type Href, useRouter } from 'expo-router'; +import { useIsFocused } from '@react-navigation/native'; import { useWallet } from '@/context/WalletContext'; import { useLxmfContext } from '@/context/LxmfContext'; import { fontFamily, fontSize, spacing } from '@/theme'; @@ -18,6 +19,7 @@ const TUTORIAL_ROUTE = '/tutorial' as Href; export default function OnboardingScreen() { const router = useRouter(); + const isFocused = useIsFocused(); const { createWallet, connectMWA, isSolanaMobile, isLoading, isConnected, isInitialized, publicKey, walletMode } = useWallet(); const { displayName: nickname } = useLxmfContext(); const insets = useSafeAreaInsets(); @@ -73,6 +75,12 @@ const overlayOpacity = useRef(new Animated.Value(0)).current; }, [router]); useEffect(() => { + // Only auto-proceed while onboarding is the focused route. unstable_settings + // anchor mounts onboarding BENEATH a deep-linked route (anonmesh://tutorial), + // and router.replace targets the focused route — without this gate, wallet + // hydration replaced the just-opened tutorial with /(tabs), so the deep link + // appeared to do nothing on devices that already have a wallet. + if (!isFocused) return; if (!isConnected || !publicKey) return; // Freshly created local wallet → offer the recovery-key backup before the // user reaches the app. One honest line: the key is device-local, here's the @@ -95,7 +103,7 @@ const overlayOpacity = useRef(new Animated.Value(0)).current; cancelled = true; clearTimeout(t); }; - }, [isConnected, publicKey, walletMode, proceed]); + }, [isFocused, isConnected, publicKey, walletMode, proceed]); const handleCreate = useCallback(async () => { if (isLoading) return; diff --git a/mobile_app/components/messages/PeersDrawer.tsx b/mobile_app/components/messages/PeersDrawer.tsx index c3c3389..5085621 100644 --- a/mobile_app/components/messages/PeersDrawer.tsx +++ b/mobile_app/components/messages/PeersDrawer.tsx @@ -60,6 +60,12 @@ function SwipeableGroupRow({ groupName, onLeave, children }: { readonly groupNam }); const rowAnim = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] })); + // The row over the action strip is transparent at rest (activeBg falls back + // to 'transparent'), so the strip can't rely on being occluded — it must + // hide itself. Fade the whole strip in from the first pixels of swipe. + const revealAnim = useAnimatedStyle(() => ({ + opacity: interpolate(tx.value, [-REVEAL * 0.15, 0], [1, 0], Extrapolation.CLAMP), + })); const actionAnim = useAnimatedStyle(() => ({ transform: [{ scale: interpolate(tx.value, [-REVEAL, -REVEAL * 0.4, 0], [1, 0.82, 0.64], Extrapolation.CLAMP), @@ -84,7 +90,7 @@ function SwipeableGroupRow({ groupName, onLeave, children }: { readonly groupNam return ( - + LEAVE - + {children} diff --git a/mobile_app/hooks/closeThread.ts b/mobile_app/hooks/closeThread.ts new file mode 100644 index 0000000..1c07a97 --- /dev/null +++ b/mobile_app/hooks/closeThread.ts @@ -0,0 +1,5 @@ +/** Module-level ref — MessagesScreen points this at its animated thread-close + * (goBack) while a thread is open, null otherwise. An open thread is screen + * state (activePeerHex), not a route, so the (tabs) layout back handler can't + * pop it — it invokes this instead, before the exit-app double-press flow. */ +export const closeThreadRef: { current: (() => void) | null } = { current: null }; diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index 8303583..756053d 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -38,6 +38,7 @@ import type { NetworkMode } from '@/src/infrastructure/network/types'; import { activeConversationRef } from '@/hooks/activeConversation'; import { pendingConversationRef } from '@/hooks/pendingConversation'; import { messagesFocusedRef } from '@/hooks/messagesFocused'; +import { closeThreadRef } from '@/hooks/closeThread'; import { useConversationSummaries } from '@/hooks/useConversationSummaries'; import { formatAgo } from '@/utils/time'; import { requestBLEPermissions } from '@/src/utils/blePermissions'; @@ -640,6 +641,14 @@ export default function MessagesScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [activePeerHex]); + // Hardware back must close an open thread before the (tabs) layout's + // exit-toast handler gets a say. The thread is component state, not a route, + // so back can't pop it — expose the animated close while a thread is open. + useEffect(() => { + closeThreadRef.current = activePeerHex !== null ? goBack : null; + return () => { closeThreadRef.current = null; }; + }, [activePeerHex, goBack]); + const chatAnim = useAnimatedStyle(() => ({ transform: [{ translateX: chatTx.value }] })); const backPan = useMemo(() => Gesture.Pan()