From 526e49674a25c5c9198c50108dd8b0846ecef444 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 10:48:30 -0800 Subject: [PATCH 1/3] fix(nav): hardware back closes open chat thread before exit-app toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread is MessagesScreen state (activePeerHex slides a chat panel over the list), not a route — so the (tabs) layout's unconditional back handler offered exit-app while a conversation was open, while the header arrow (goBack) worked. Bridge with the existing module-ref pattern (drawerState, activeConversation, messagesFocused): MessagesScreen exposes its animated close via closeThreadRef while a thread is open and clears it on close/unmount; the (tabs) back handler invokes it first and only when the Messages tab is focused, so a thread left open on the frozen tab can't swallow back presses from other tabs. --- mobile_app/app/(tabs)/_layout.tsx | 9 +++++++++ mobile_app/hooks/closeThread.ts | 5 +++++ mobile_app/screens/MessagesScreen.tsx | 9 +++++++++ 3 files changed, 23 insertions(+) create mode 100644 mobile_app/hooks/closeThread.ts 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/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() From d598075a8d30cd9065b89867a6051e6cafb821de Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 10:48:50 -0800 Subject: [PATCH 2/3] fix(nav): stop onboarding auto-proceed from hijacking deep-linked routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anonmesh://tutorial resolves correctly (verified on device: warm app on Messages, backgrounded, and on onboarding all navigate). The breakage is the cold/anchored path: unstable_settings.anchor mounts onboarding BENEATH a deep-linked tutorial, and onboarding's wallet-hydration effect then fires router.replace('/(tabs)') — replace targets the focused route, so it kills the tutorial the user just deep-linked into. On a device with a wallet + completed tutorial that reads as 'link does nothing, app sits on Messages'. Gate the auto-proceed on useIsFocused(): onboarding only self-navigates while it is actually the focused route. Normal flows are unchanged (cold start lands focused on onboarding; back-nav re-focuses it). Boundary: on dev-client builds a cold deep link lands in the launcher (and the launcher replays the LAST stored link on reconnect), so cold replay can only be exercised on release builds. --- mobile_app/app/onboarding.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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; From 0756181fadd135210fae3f5dc64419c9f356fac3 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 10:49:06 -0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(messages):=20group=20rows=20rest=20clos?= =?UTF-8?q?ed=20=E2=80=94=20hide=20LEAVE=20strip=20until=20swiped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every group row showed its red LEAVE action with no swipe performed. The strip is an absolutely-positioned opaque red block behind the row, and the row's resting background is 'transparent' (activeBg falls back to transparent, design tokens pass kept it that way) — so tx=0 'closed' just parks a see-through cover over a fully painted strip. The shared value and gesture were never wrong; the strip was simply never hidden. Drive the strip's own opacity from tx (0 at rest → 1 within the first ~12px of swipe). Z-order already routes taps to the row when closed, so swipe, LEAVE tap, confirm sheet, and snap-back stay as they were. Device-verified: three group rows rest closed; swiping one reveals LEAVE for that row only; cancel snaps it closed. --- mobile_app/components/messages/PeersDrawer.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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}