From 5144459b8a25ca2a6d744efe2ffe3ef723479119 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:42:53 -0800 Subject: [PATCH 1/5] refactor(wallet): move dead Send/Swap/Yield panels out of barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These four files (WalletTabs, SendPanel, SwapPanel, YieldPanel) are roadmap-preview surfaces with no live behavior — grep confirms zero consumers outside the barrel. The barrel re-export keeps them one casual import away from showing up in the wallet bento with theatre copy (MPC 3/3, JITO_RATE, dead Deposit/Withdraw). Removes the four exports from components/wallet/index.ts and adds a FUTURE header comment to each panel pointing back to AUDIT A6 / ROADMAP § 0.A.8. Files stay so the design isn't lost — re-export when each is wired and wrapped in . --- mobile_app/components/wallet/SendPanel.tsx | 3 +++ mobile_app/components/wallet/SwapPanel.tsx | 3 +++ mobile_app/components/wallet/WalletTabs.tsx | 3 +++ mobile_app/components/wallet/YieldPanel.tsx | 3 +++ mobile_app/components/wallet/index.ts | 8 ++++---- 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/mobile_app/components/wallet/SendPanel.tsx b/mobile_app/components/wallet/SendPanel.tsx index 484a74a9..72cab1e2 100644 --- a/mobile_app/components/wallet/SendPanel.tsx +++ b/mobile_app/components/wallet/SendPanel.tsx @@ -1,3 +1,6 @@ +// FUTURE: roadmap preview. Not exported from `components/wallet/index.ts` — +// re-add the export only after wiring real behavior and wrapping CTAs in +// . Per AUDIT A6 / ROADMAP § 0.A.8. import React, { memo, useState, useEffect } from 'react'; import { View, Text, TextInput, Pressable, ScrollView, StyleSheet } from 'react-native'; import { Feather } from '@expo/vector-icons'; diff --git a/mobile_app/components/wallet/SwapPanel.tsx b/mobile_app/components/wallet/SwapPanel.tsx index db24aebf..41e3ffb3 100644 --- a/mobile_app/components/wallet/SwapPanel.tsx +++ b/mobile_app/components/wallet/SwapPanel.tsx @@ -1,3 +1,6 @@ +// FUTURE: roadmap preview. Not exported from `components/wallet/index.ts` — +// re-add the export only after wiring real behavior and wrapping CTAs in +// . Per AUDIT A6 / ROADMAP § 0.A.8. import React, { memo, useState, useEffect } from 'react'; import { View, Text, Pressable, StyleSheet } from 'react-native'; import { Feather } from '@expo/vector-icons'; diff --git a/mobile_app/components/wallet/WalletTabs.tsx b/mobile_app/components/wallet/WalletTabs.tsx index a319dd9c..231f5de8 100644 --- a/mobile_app/components/wallet/WalletTabs.tsx +++ b/mobile_app/components/wallet/WalletTabs.tsx @@ -1,3 +1,6 @@ +// FUTURE: roadmap preview. Not exported from `components/wallet/index.ts` — +// re-add the export only after wiring real behavior and wrapping CTAs in +// . Per AUDIT A6 / ROADMAP § 0.A.8. import React, { memo } from 'react'; import { View, Text, Pressable, StyleSheet } from 'react-native'; import { Feather } from '@expo/vector-icons'; diff --git a/mobile_app/components/wallet/YieldPanel.tsx b/mobile_app/components/wallet/YieldPanel.tsx index de3384dd..a4c7626a 100644 --- a/mobile_app/components/wallet/YieldPanel.tsx +++ b/mobile_app/components/wallet/YieldPanel.tsx @@ -1,3 +1,6 @@ +// FUTURE: roadmap preview. Not exported from `components/wallet/index.ts` — +// re-add the export only after wiring real behavior and wrapping CTAs in +// . Per AUDIT A6 / ROADMAP § 0.A.8. import React, { memo, useState } from 'react'; import { View, Text, Pressable, StyleSheet } from 'react-native'; import { fontFamily, useTheme } from '@/theme'; diff --git a/mobile_app/components/wallet/index.ts b/mobile_app/components/wallet/index.ts index d4f87b45..c3a51ca6 100644 --- a/mobile_app/components/wallet/index.ts +++ b/mobile_app/components/wallet/index.ts @@ -1,8 +1,8 @@ export { AssetDot } from './AssetDot'; -export { WalletTabs } from './WalletTabs'; -export { SendPanel } from './SendPanel'; export { ReceivePanel } from './ReceivePanel'; export { SwapRow } from './SwapRow'; -export { SwapPanel } from './SwapPanel'; -export { YieldPanel } from './YieldPanel'; export type { Asset, Tab } from './types'; + +// FUTURE: WalletTabs, SendPanel, SwapPanel, YieldPanel are roadmap-preview +// surfaces. Re-export here only when each is wired to live behavior and +// wrapped in . See AUDIT A6 + ROADMAP § 0.A.8. From 704123acb1ff60905dc3612b04a8c17ab0b92d01 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:44:13 -0800 Subject: [PATCH 2/5] refactor(network): extract solanaConnection singleton Connection construction lived inside sendTransaction.ts, which made the singleton's existence a side-effect of importing the send service. Three hooks already reached past it. Pulls the RPC_URL resolution + new Connection into src/infrastructure/network/connection.ts; sendTransaction.ts now imports and re-exports it so existing callers don't churn. useNetworkMode + useWalletBalance read from the new module directly. ARCH P2 #1. --- mobile_app/src/hooks/useNetworkMode.ts | 2 +- mobile_app/src/hooks/useWalletBalance.tsx | 2 +- .../src/infrastructure/network/connection.ts | 15 +++++++++++++++ mobile_app/src/services/sendTransaction.ts | 17 +++++------------ 4 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 mobile_app/src/infrastructure/network/connection.ts diff --git a/mobile_app/src/hooks/useNetworkMode.ts b/mobile_app/src/hooks/useNetworkMode.ts index 684b7b31..0e1bf356 100644 --- a/mobile_app/src/hooks/useNetworkMode.ts +++ b/mobile_app/src/hooks/useNetworkMode.ts @@ -3,7 +3,7 @@ import "@/polyfills"; import NetInfo from '@react-native-community/netinfo'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useLxmfContext } from '@/context/LxmfContext'; -import { solanaConnection } from '@/src/services/sendTransaction'; +import { solanaConnection } from '@/src/infrastructure/network/connection'; import { DirectRpcAdapter } from '../infrastructure/network/DirectRpcAdapter'; import { IsolatedRpcAdapter } from '../infrastructure/network/IsolatedRpcAdapter'; import { MeshRpcAdapter } from '../infrastructure/network/MeshRpcAdapter'; diff --git a/mobile_app/src/hooks/useWalletBalance.tsx b/mobile_app/src/hooks/useWalletBalance.tsx index 336d110f..ec355347 100644 --- a/mobile_app/src/hooks/useWalletBalance.tsx +++ b/mobile_app/src/hooks/useWalletBalance.tsx @@ -2,7 +2,7 @@ import React, { createContext, ReactNode, useCallback, useContext, useEffect, us import { useWallet } from "@/context/WalletContext"; import { useNetworkMode } from "@/src/hooks/useNetworkMode"; -import { solanaConnection } from "@/src/services/sendTransaction"; +import { solanaConnection } from "@/src/infrastructure/network/connection"; import { ActivityEntry, SOL_DECIMALS, diff --git a/mobile_app/src/infrastructure/network/connection.ts b/mobile_app/src/infrastructure/network/connection.ts new file mode 100644 index 00000000..868eb33f --- /dev/null +++ b/mobile_app/src/infrastructure/network/connection.ts @@ -0,0 +1,15 @@ +import "@/polyfills"; + +import { Connection } from "@solana/web3.js"; + +// Devnet-only for safety. Mainnet wiring is a deliberate future decision — +// we don't want mainnet funds going out via a dev build. +// +// EXPO_PUBLIC_SOLANA_RPC lets teams point at a dedicated devnet endpoint +// (Helius / QuickNode / Triton free tier) to avoid the public endpoint's +// 429 rate-limits. Falls back to the public endpoint when unset so cloning +// the repo "just works". +const DEFAULT_DEVNET_RPC = "https://api.devnet.solana.com"; +const RPC_URL = process.env.EXPO_PUBLIC_SOLANA_RPC || DEFAULT_DEVNET_RPC; + +export const solanaConnection = new Connection(RPC_URL, "confirmed"); diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 3c6b13fb..cff9a62a 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -1,7 +1,6 @@ import "@/polyfills"; import { - Connection, Keypair, PublicKey, SystemProgram, @@ -16,6 +15,7 @@ import { transact } from "@solana-mobile/mobile-wallet-adapter-protocol-web3js"; import { Buffer } from "buffer"; import type { IRpcAdapter } from "@/src/infrastructure/network"; +import { solanaConnection } from "@/src/infrastructure/network/connection"; import type { IWalletAdapter } from "@/src/infrastructure/wallet"; import { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; import { assertSendableSplProgram } from "@/src/services/walletData"; @@ -29,17 +29,10 @@ const APP_IDENTITY = { icon: "/favicon.ico", }; -// Devnet-only for safety. Mainnet wiring is a deliberate future -// decision — we don't want mainnet funds going out via a dev build. -// -// EXPO_PUBLIC_SOLANA_RPC lets teams point at a dedicated devnet -// endpoint (Helius / QuickNode / Triton free tier) to avoid the -// public endpoint's 429 rate-limits. Falls back to the public -// endpoint when unset so cloning the repo "just works". -const DEFAULT_DEVNET_RPC = "https://api.devnet.solana.com"; -const RPC_URL = process.env.EXPO_PUBLIC_SOLANA_RPC || DEFAULT_DEVNET_RPC; - -export const solanaConnection = new Connection(RPC_URL, "confirmed"); +// Re-export so existing consumers (`@/src/services/sendTransaction`) keep +// working without import churn. The singleton lives in +// `src/infrastructure/network/connection.ts`. +export { solanaConnection }; export interface SendSolParams { walletAdapter: IWalletAdapter; From d1c7441a14c20954fba9c3ea3633a8107c1cad67 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:46:07 -0800 Subject: [PATCH 3/5] refactor: consolidate sliceNewEvents to single util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'detect newly-prepended LXMF events given prev head + prev count' helper had drifted into three copies — LxmfContext (most defensive: handles old anchor falling off the end), MessagesScreen, useMessageNotifications. The notification + screen copies returned [] when the previous head disappeared from the (capped) buffer, silently dropping a window of events on restart. Pulls the LxmfContext semantics into src/utils/sliceNewEvents.ts and points all three callers at it. ARCH P2 #4. --- mobile_app/context/LxmfContext.tsx | 18 +------------- mobile_app/hooks/useMessageNotifications.ts | 13 +--------- mobile_app/screens/MessagesScreen.tsx | 13 +--------- mobile_app/src/utils/sliceNewEvents.ts | 27 +++++++++++++++++++++ 4 files changed, 30 insertions(+), 41 deletions(-) create mode 100644 mobile_app/src/utils/sliceNewEvents.ts diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index 4d9f89c4..d3b3b9c4 100644 --- a/mobile_app/context/LxmfContext.tsx +++ b/mobile_app/context/LxmfContext.tsx @@ -18,6 +18,7 @@ import { } from '@magicred-1/react-native-lxmf'; import { generateNickname } from '@/components/onboarding/constants'; import { requestBLEPermissions } from '@/src/utils/blePermissions'; +import { sliceNewEvents } from '@/src/utils/sliceNewEvents'; import * as ExpoCrypto from 'expo-crypto'; const IDENTITY_SCHEMA_VERSION = 1; @@ -130,23 +131,6 @@ function applyAnnounceEvent( const ANNOUNCE_LOG_RE = /announce from ([0-9a-f]{32}) \((\d+) hops\)/; -// Events are prepended newest-first and capped at 200. Once capped, length -// never grows, so we detect new prepended events by reference comparison. -function sliceNewEvents( - events: LxmfEvent[], - prevCount: number, - prevFirst: LxmfEvent | null, -): LxmfEvent[] { - if (events.length > prevCount) return events.slice(0, events.length - prevCount); - const first = events[0] ?? null; - if (prevFirst !== null && first !== prevFirst) { - const oldIdx = events.indexOf(prevFirst); - if (oldIdx === -1) return events; - return oldIdx > 0 ? events.slice(0, oldIdx) : []; - } - return []; -} - // Fallback: parse log events for announces (library compat across versions) function applyLogAnnounce( e: LxmfEvent, map: PeerMap, names: NameDict, now: number, ownHash: string | undefined, diff --git a/mobile_app/hooks/useMessageNotifications.ts b/mobile_app/hooks/useMessageNotifications.ts index 5b979268..9b04de63 100644 --- a/mobile_app/hooks/useMessageNotifications.ts +++ b/mobile_app/hooks/useMessageNotifications.ts @@ -3,6 +3,7 @@ import { AppState, Platform } from 'react-native'; import * as Notifications from 'expo-notifications'; import type { LxmfEvent } from '@magicred-1/react-native-lxmf'; import { useLxmfContext } from '@/context/LxmfContext'; +import { sliceNewEvents } from '@/src/utils/sliceNewEvents'; import type { NotificationPayload } from '@/components/ui/InAppNotificationBanner'; import { activeConversationRef } from './activeConversation'; import { messagesFocusedRef } from './messagesFocused'; @@ -30,18 +31,6 @@ if (Platform.OS === 'android') { }).catch(() => {}); } -// Events are prepended newest-first and capped at 200 — use reference comparison when capped. -function sliceNewEvents( - events: LxmfEvent[], prevCount: number, prevFirst: LxmfEvent | null, -): LxmfEvent[] { - if (events.length > prevCount) return events.slice(0, events.length - prevCount); - const first = events[0] ?? null; - if (prevFirst !== null && first !== prevFirst) { - const oldIdx = events.indexOf(prevFirst); - return oldIdx > 0 ? events.slice(0, oldIdx) : []; - } - return []; -} export function useMessageNotifications( onInApp: (n: NotificationPayload) => void, diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index 85c60752..7f24c47c 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -60,6 +60,7 @@ function decodeBody(raw: string): string { } import type { LxmfEvent } from '@magicred-1/react-native-lxmf'; +import { sliceNewEvents } from '@/src/utils/sliceNewEvents'; let _msgId = Date.now(); const nextId = () => ++_msgId; @@ -77,18 +78,6 @@ function renderMsg(m: AnyMsg, getSendState: GetSendState): React.ReactElement { return ; } -function sliceNewEvents( - events: LxmfEvent[], prevCount: number, prevFirst: LxmfEvent | null, -): LxmfEvent[] { - if (events.length > prevCount) return events.slice(0, events.length - prevCount); - const first = events[0] ?? null; - if (prevFirst !== null && first !== prevFirst) { - const oldIdx = events.indexOf(prevFirst); - return oldIdx > 0 ? events.slice(0, oldIdx) : []; - } - return []; -} - // ── Helpers ────────────────────────────────────────────────────────────────── const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; diff --git a/mobile_app/src/utils/sliceNewEvents.ts b/mobile_app/src/utils/sliceNewEvents.ts new file mode 100644 index 00000000..48aeebc1 --- /dev/null +++ b/mobile_app/src/utils/sliceNewEvents.ts @@ -0,0 +1,27 @@ +import type { LxmfEvent } from "@magicred-1/react-native-lxmf"; + +/** + * The LXMF event log is prepended newest-first and capped at 200 entries. Once + * the cap is hit length stops growing, so callers track the previous head by + * reference to detect new prepended events. + * + * Three semantics for the return value: + * - length grew → return the prefix slice that's new + * - length capped but head moved → return the prefix up to the previous head + * (or everything if the previous head fell off the end) + * - head unchanged → return [] + */ +export function sliceNewEvents( + events: LxmfEvent[], + prevCount: number, + prevFirst: LxmfEvent | null, +): LxmfEvent[] { + if (events.length > prevCount) return events.slice(0, events.length - prevCount); + const first = events[0] ?? null; + if (prevFirst !== null && first !== prevFirst) { + const oldIdx = events.indexOf(prevFirst); + if (oldIdx === -1) return events; + return oldIdx > 0 ? events.slice(0, oldIdx) : []; + } + return []; +} From 6bfecf5fe157ed332eb158359e1b34b69fe1460a Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:47:01 -0800 Subject: [PATCH 4/5] refactor: consolidate relTime formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WalletScreen + PendingCosigns each carried their own 'X ago' helper. PendingCosigns was missing the days branch so cosign requests over 24h stale would say '36h ago' instead of '1d ago'. Hoists the WalletScreen version (with the days branch) into src/utils/relTime.ts. Spec said WalletScreen + RecentActivity, but RecentActivity actually uses Intl.DateTimeFormat — the duplication is in PendingCosigns. Logged the drift in WORK_LOG. --- mobile_app/components/nodes/PendingCosigns.tsx | 8 +------- mobile_app/screens/WalletScreen.tsx | 9 +-------- mobile_app/src/utils/relTime.ts | 12 ++++++++++++ 3 files changed, 14 insertions(+), 15 deletions(-) create mode 100644 mobile_app/src/utils/relTime.ts diff --git a/mobile_app/components/nodes/PendingCosigns.tsx b/mobile_app/components/nodes/PendingCosigns.tsx index 0565391e..c4073fe7 100644 --- a/mobile_app/components/nodes/PendingCosigns.tsx +++ b/mobile_app/components/nodes/PendingCosigns.tsx @@ -6,6 +6,7 @@ import { import { Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { fontFamily, useTheme } from '@/theme'; import { useGlass } from '@/hooks/useGlass'; +import { relTime } from '@/src/utils/relTime'; export interface PendingCosign { id: string; @@ -20,13 +21,6 @@ interface Props { items: PendingCosign[]; } -function relTime(ms: number) { - const d = Date.now() - ms; - if (d < 60_000) return 'just now'; - if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`; - return `${Math.floor(d / 3_600_000)}h ago`; -} - function short(hash: string) { return `${hash.slice(0, 6)}…${hash.slice(-6)}`; } diff --git a/mobile_app/screens/WalletScreen.tsx b/mobile_app/screens/WalletScreen.tsx index 7aed8399..09d44858 100644 --- a/mobile_app/screens/WalletScreen.tsx +++ b/mobile_app/screens/WalletScreen.tsx @@ -17,6 +17,7 @@ import { useHideBalance } from '@/src/hooks/useHideBalance'; import { useWalletBalance } from '@/src/hooks/useWalletBalance'; import { useNetworkMode } from '@/src/hooks/useNetworkMode'; import type { ActivityEntry, TokenBalance } from '@/src/services/walletData'; +import { relTime } from '@/src/utils/relTime'; import { fontFamily, useTheme } from '@/theme'; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -41,14 +42,6 @@ const TOKEN_COLOR: Record = { JUP: '#C7F284', BONK: '#FFB020', }; -function relTime(ms: number) { - const d = Date.now() - ms; - if (d < 60_000) return 'just now'; - if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`; - if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`; - return `${Math.floor(d / 86_400_000)}d ago`; -} - // ── tiles ───────────────────────────────────────────────────────────────────── function BalanceTile({ hidden, toggle }: { readonly hidden: boolean; readonly toggle: () => void }) { diff --git a/mobile_app/src/utils/relTime.ts b/mobile_app/src/utils/relTime.ts new file mode 100644 index 00000000..4ff85d54 --- /dev/null +++ b/mobile_app/src/utils/relTime.ts @@ -0,0 +1,12 @@ +/** + * Format a unix epoch in ms as "5m ago" / "2h ago" / "3d ago". + * + * Returns "just now" under 60s. + */ +export function relTime(ms: number): string { + const d = Date.now() - ms; + if (d < 60_000) return "just now"; + if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`; + if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`; + return `${Math.floor(d / 86_400_000)}d ago`; +} From 7d45926d1f73cddb5d51696a66cd479e018917a9 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Thu, 14 May 2026 04:48:33 -0800 Subject: [PATCH 5/5] refactor(network): promote useNetworkMode to context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five active call sites each got their own NetInfo subscription + a fresh MeshRpcAdapter — adapter has a stateful pending-request map and the rpcResponse routing useEffect ran in only one of them, so simultaneous mounts could mute responses for the others (T23). Lifts the entire hook logic into context/NetworkModeContext.tsx with a Provider mounted in app/_layout.tsx inside LxmfProvider (depends on it) and outside WalletBalanceProvider (consumer). src/hooks/useNetworkMode.ts is now a 4-line re-export shim so existing imports stay valid. ROADMAP § 2.6 / ARCH P2 #3. --- mobile_app/app/_layout.tsx | 17 +-- mobile_app/context/NetworkModeContext.tsx | 140 ++++++++++++++++++++++ mobile_app/src/hooks/useNetworkMode.ts | 115 +----------------- 3 files changed, 155 insertions(+), 117 deletions(-) create mode 100644 mobile_app/context/NetworkModeContext.tsx diff --git a/mobile_app/app/_layout.tsx b/mobile_app/app/_layout.tsx index e38dc3af..0927e5ba 100644 --- a/mobile_app/app/_layout.tsx +++ b/mobile_app/app/_layout.tsx @@ -21,6 +21,7 @@ import { Feather } from '@expo/vector-icons'; import { ThemeProvider, useTheme } from '@/theme'; import { WalletProvider } from '@/context/WalletContext'; import { LxmfProvider, useLxmfContext } from '@/context/LxmfContext'; +import { NetworkModeProvider } from '@/context/NetworkModeContext'; import { HideBalanceProvider } from '@/src/hooks/useHideBalance'; import { WalletBalanceProvider } from '@/src/hooks/useWalletBalance'; import { InAppNotificationBanner, type NotificationPayload } from '@/components/ui/InAppNotificationBanner'; @@ -157,13 +158,15 @@ export default function RootLayout() { - - - - - - - + + + + + + + + + diff --git a/mobile_app/context/NetworkModeContext.tsx b/mobile_app/context/NetworkModeContext.tsx new file mode 100644 index 00000000..c2d26293 --- /dev/null +++ b/mobile_app/context/NetworkModeContext.tsx @@ -0,0 +1,140 @@ +import "@/polyfills"; + +import NetInfo from "@react-native-community/netinfo"; +import React, { + createContext, + ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { useLxmfContext } from "@/context/LxmfContext"; +import { solanaConnection } from "@/src/infrastructure/network/connection"; +import { DirectRpcAdapter } from "@/src/infrastructure/network/DirectRpcAdapter"; +import { IsolatedRpcAdapter } from "@/src/infrastructure/network/IsolatedRpcAdapter"; +import { MeshRpcAdapter } from "@/src/infrastructure/network/MeshRpcAdapter"; +import type { IRpcAdapter, NetworkMode } from "@/src/infrastructure/network/types"; + +// Beacon must be active and recently announced to be considered a usable +// Solana relay route. Plain peers / BLE are mesh presence, not RPC transport. +const BEACON_STALE_MS = 120_000; +const EPOCH_MS_THRESHOLD = 10_000_000_000; + +function announceMillis(lastAnnounce: number): number { + return lastAnnounce > EPOCH_MS_THRESHOLD ? lastAnnounce : lastAnnounce * 1000; +} + +function freshRelayBeacon( + beacons: { destHash: string; state: string; lastAnnounce: number }[], + ownHash: string | null | undefined, +) { + const now = Date.now(); + return [...beacons] + .filter((b) => + b.state === "active" && + b.destHash !== ownHash && + now - announceMillis(b.lastAnnounce) < BEACON_STALE_MS, + ) + .sort((a, b) => announceMillis(b.lastAnnounce) - announceMillis(a.lastAnnounce))[0] ?? null; +} + +function hasInternetRoute(state: { + isConnected: boolean | null; + isInternetReachable: boolean | null; +}) { + // On Android/iOS, NetInfo can report isInternetReachable=null while the + // device is connected. Treat only explicit false as offline so Solana sends + // do not get misrouted to mesh when normal internet is available. + return state.isConnected === true && state.isInternetReachable !== false; +} + +export interface NetworkState { + mode: NetworkMode; + adapter: IRpcAdapter; + relayHash: string | null; +} + +const NetworkModeContext = createContext(undefined); + +export function NetworkModeProvider({ children }: { children: ReactNode }) { + const { beacons, send, events, status } = useLxmfContext(); + const [internet, setInternet] = useState(true); + + // Subscribe to OS-level connectivity — no polling, no HTTP spam. + useEffect(() => { + const unsub = NetInfo.addEventListener((state) => { + setInternet(hasInternetRoute(state)); + }); + NetInfo.fetch().then((state) => { + setInternet(hasInternetRoute(state)); + }); + return unsub; + }, []); + + const relay = useMemo( + () => freshRelayBeacon(beacons, status?.addressHex), + [beacons, status?.addressHex], + ); + + let mode: NetworkMode; + if (internet) { + mode = "online"; + } else if (relay) { + mode = "mesh"; + } else { + mode = "isolated"; + } + + // Stable adapter ref — single MeshRpcAdapter instance across the app so + // pending-request maps + rpcResponse routing don't fragment under + // simultaneous mounts. Fixes T23. + const meshAdapterRef = useRef(null); + const adapter = useMemo(() => { + if (mode === "online") { + meshAdapterRef.current = null; + return new DirectRpcAdapter(solanaConnection); + } + if (mode === "mesh" && relay) { + const a = new MeshRpcAdapter(relay.destHash, send); + meshAdapterRef.current = a; + return a; + } + meshAdapterRef.current = null; + return new IsolatedRpcAdapter(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, relay?.destHash]); + + // Route incoming LXMF messages + rpcResponse events to the active mesh adapter. + useEffect(() => { + const mesh = meshAdapterRef.current; + if (!mesh || events.length === 0) return; + const last = events[0]; + if (last?.type === "messageReceived" && last.source && last.body) { + const decoded = Buffer.from(last.body as string, "base64").toString("utf8"); + mesh.handleIncoming(last.source as string, decoded); + } + if (last?.type === "rpcResponse") { + mesh.handleIncoming(last.source as string ?? "", last.body as string ?? ""); + } + }, [events]); + + const value = useMemo( + () => ({ mode, adapter, relayHash: adapter.relayHash }), + [mode, adapter], + ); + + return ( + {children} + ); +} + +export function useNetworkMode(): NetworkState { + const ctx = useContext(NetworkModeContext); + if (!ctx) { + throw new Error("useNetworkMode must be used within NetworkModeProvider"); + } + return ctx; +} diff --git a/mobile_app/src/hooks/useNetworkMode.ts b/mobile_app/src/hooks/useNetworkMode.ts index 0e1bf356..358ad8f3 100644 --- a/mobile_app/src/hooks/useNetworkMode.ts +++ b/mobile_app/src/hooks/useNetworkMode.ts @@ -1,110 +1,5 @@ -import "@/polyfills"; - -import NetInfo from '@react-native-community/netinfo'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useLxmfContext } from '@/context/LxmfContext'; -import { solanaConnection } from '@/src/infrastructure/network/connection'; -import { DirectRpcAdapter } from '../infrastructure/network/DirectRpcAdapter'; -import { IsolatedRpcAdapter } from '../infrastructure/network/IsolatedRpcAdapter'; -import { MeshRpcAdapter } from '../infrastructure/network/MeshRpcAdapter'; -import type { IRpcAdapter, NetworkMode } from '../infrastructure/network/types'; - -// Beacon must be active and recently announced to be considered a usable -// Solana relay route. Plain peers/BLE are mesh presence, not RPC transport. -const BEACON_STALE_MS = 120_000; -const EPOCH_MS_THRESHOLD = 10_000_000_000; - -function announceMillis(lastAnnounce: number): number { - return lastAnnounce > EPOCH_MS_THRESHOLD ? lastAnnounce : lastAnnounce * 1000; -} - -function freshRelayBeacon( - beacons: { destHash: string; state: string; lastAnnounce: number }[], - ownHash: string | null | undefined, -) { - const now = Date.now(); - return [...beacons] - .filter((b) => - b.state === "active" && - b.destHash !== ownHash && - now - announceMillis(b.lastAnnounce) < BEACON_STALE_MS, - ) - .sort((a, b) => announceMillis(b.lastAnnounce) - announceMillis(a.lastAnnounce))[0] ?? null; -} - -function hasInternetRoute(state: { - isConnected: boolean | null; - isInternetReachable: boolean | null; -}) { - // On Android/iOS, NetInfo can report isInternetReachable=null while the - // device is connected. Treat only explicit false as offline so Solana sends - // do not get misrouted to mesh when normal internet is available. - return state.isConnected === true && state.isInternetReachable !== false; -} - -export interface NetworkState { - mode: NetworkMode; - adapter: IRpcAdapter; - relayHash: string | null; -} - -export function useNetworkMode(): NetworkState { - const { beacons, send, events, status } = useLxmfContext(); - const [internet, setInternet] = useState(true); - - // Subscribe to OS-level connectivity — no polling, no HTTP spam. - useEffect(() => { - const unsub = NetInfo.addEventListener((state) => { - setInternet(hasInternetRoute(state)); - }); - // Fetch once on mount so initial state is correct before first event. - NetInfo.fetch().then((state) => { - setInternet(hasInternetRoute(state)); - }); - return unsub; - }, []); - - const relay = useMemo(() => freshRelayBeacon(beacons, status?.addressHex), [beacons, status?.addressHex]); - - let mode: NetworkMode; - if (internet) { - mode = 'online'; - } else if (relay) { - mode = 'mesh'; - } else { - mode = 'isolated'; - } - - // Stable adapter refs — recreate only when mode or relay changes. - const meshAdapterRef = useRef(null); - const adapter = useMemo(() => { - if (mode === 'online') { - meshAdapterRef.current = null; - return new DirectRpcAdapter(solanaConnection); - } - if (mode === 'mesh' && relay) { - const a = new MeshRpcAdapter(relay.destHash, send); - meshAdapterRef.current = a; - return a; - } - meshAdapterRef.current = null; - return new IsolatedRpcAdapter(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, relay?.destHash]); - - // Route incoming LXMF messages and rpcResponse events to the active MeshRpcAdapter. - useEffect(() => { - const mesh = meshAdapterRef.current; - if (!mesh || events.length === 0) return; - const last = events[0]; - if (last?.type === 'messageReceived' && last.source && last.body) { - const decoded = Buffer.from(last.body as string, 'base64').toString('utf8'); - mesh.handleIncoming(last.source as string, decoded); - } - if (last?.type === 'rpcResponse') { - mesh.handleIncoming(last.source as string ?? '', last.body as string ?? ''); - } - }, [events]); - - return { mode, adapter, relayHash: adapter.relayHash }; -} +// useNetworkMode lives in `context/NetworkModeContext` so the MeshRpcAdapter +// + NetInfo subscription stay singletons across the app. This module +// re-exports the hook + types so existing import paths keep working. +export { useNetworkMode, NetworkModeProvider } from "@/context/NetworkModeContext"; +export type { NetworkState } from "@/context/NetworkModeContext";