From 1fb17a471c9fc5a25c2d8bf5041cb4e9d77ace43 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 09:26:22 -0800 Subject: [PATCH 1/2] fix(peers): show honest staleness for hub peers when off-grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peer.online means "announced inside the 10-min fresh window", not "reachable now" — with no internet route every hub peer kept its green dot and 'reticulum · active' label. Add isPeerReachable(peer, mode): reachable = online && (mode online || via is ble/rnode). - All Peers rows: unreachable hub peers drop to gray dot + 'reticulum · last seen 3m' instead of claiming active - header count: 'N online' -> 'off-grid · N reachable' when offline, counting only radio-local peers - BLE/RNode peers stay live off-grid; online mode unchanged --- .../components/messages/PeersDrawer.tsx | 6 +++- mobile_app/context/LxmfContext.tsx | 11 +++++++ mobile_app/screens/MessagesScreen.tsx | 29 ++++++++++++------- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/mobile_app/components/messages/PeersDrawer.tsx b/mobile_app/components/messages/PeersDrawer.tsx index fc12549..c3c3389 100644 --- a/mobile_app/components/messages/PeersDrawer.tsx +++ b/mobile_app/components/messages/PeersDrawer.tsx @@ -12,6 +12,7 @@ import { Skeleton } from '@/components/ui/Skeleton'; import { confirm } from '@/components/ui/ConfirmSheet'; import { useGlass } from '../../hooks/useGlass'; import { scan } from '@/src/services/qrScan'; +import { useNetworkMode } from '@/src/hooks/useNetworkMode'; import { type Peer } from './constants'; import type { ConvSummary } from '@/hooks/useConversationSummaries'; @@ -294,10 +295,13 @@ export const PeersDrawer = memo(function PeersDrawer({ }: Props) { const { colors } = useTheme(); const softGlass = useGlass('soft'); + const { mode } = useNetworkMode(); const allPeers = peersProp ?? []; const groups = allPeers.filter(p => p.isGroup); const dmPeers = allPeers.filter(p => !p.isGroup); + // p.online is reachability-honest (isPeerReachable upstream), so off-grid + // this counts only radio-local peers — known-but-unreachable peers stay out. const online = dmPeers.filter(p => p.online).length; const [input, setInput] = useState(''); @@ -373,7 +377,7 @@ export const PeersDrawer = memo(function PeersDrawer({ messages - {online} online + {mode === 'online' ? `${online} online` : `off-grid · ${online} reachable`} diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index ba9e5cd..e0143b6 100644 --- a/mobile_app/context/LxmfContext.tsx +++ b/mobile_app/context/LxmfContext.tsx @@ -17,6 +17,7 @@ import { type TcpInterface, } from '@magicred-1/react-native-lxmf'; import { generateNickname } from '@/components/onboarding/constants'; +import type { NetworkMode } from '@/src/infrastructure/network/types'; import { requestBLEPermissions } from '@/src/utils/blePermissions'; import { eventsAfter, highestEventId } from '@/src/utils/eventsAfter'; import { collectPeerMessages } from '@/src/services/peerMessages'; @@ -475,6 +476,16 @@ export interface LxmfPeer { nameKnown: boolean; } +// `online` records "announced within the fresh window", not "reachable right +// now" — it sticks true for up to PEER_FRESH_WINDOW_SEC after the last hub +// announce. Hub-sourced ('reticulum'/TCP) peers are only reachable while this +// device holds an internet route; BLE and RNode links are radio-local and +// survive off-grid. UI must not claim "active" past what this returns. +export function isPeerReachable(p: LxmfPeer, mode: NetworkMode): boolean { + if (!p.online) return false; + return mode === 'online' || p.via !== 'reticulum'; +} + interface LxmfCtxValue { isRunning: boolean; isNativeAvailable: boolean; diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index f05c150..8303583 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -32,7 +32,9 @@ import { Feather } from '@expo/vector-icons'; import { useWallet } from '@/context/WalletContext'; import type { AnyMsg, ChatMsg } from '@/components/messages/types'; import type { MediaPayload } from '@/components/messages/Composer'; -import type { LxmfPeer, StoredMessage } from '@/context/LxmfContext'; +import { isPeerReachable, type LxmfPeer, type StoredMessage } from '@/context/LxmfContext'; +import { useNetworkMode } from '@/src/hooks/useNetworkMode'; +import type { NetworkMode } from '@/src/infrastructure/network/types'; import { activeConversationRef } from '@/hooks/activeConversation'; import { pendingConversationRef } from '@/hooks/pendingConversation'; import { messagesFocusedRef } from '@/hooks/messagesFocused'; @@ -125,16 +127,22 @@ function viaToIface(via: LxmfPeer['via']): 'BLE' | 'TCP' | 'RNode' { return 'TCP'; } -function lxmfPeerToPeer(p: LxmfPeer): Peer { +function lxmfPeerToPeer(p: LxmfPeer, mode: NetworkMode): Peer { const now = Math.floor(Date.now() / 1000); const ago = p.lastSeen > 0 ? formatAgo(now - p.lastSeen) : '—'; + const reachable = isPeerReachable(p, mode); + // Stale-announce honesty: off-grid, a hub peer still inside the fresh window + // (online=true) is not "active" — say when it was last heard instead. + let last = `${p.via} · offline`; + if (reachable) last = `${p.via} · active`; + else if (p.online) last = `${p.via} · last seen ${ago}`; return { handle: p.nameKnown ? p.displayName : p.destHash.slice(0, 8), hops: p.hops, iface: viaToIface(p.via), - online: p.online, + online: reachable, unread: 0, - last: `${p.via} · ${p.online ? 'active' : 'offline'}`, + last, time: ago, beacon: false, destHash: p.destHash, @@ -352,6 +360,7 @@ export default function MessagesScreen() { groups, createGroup, leaveGroup, getGroupMembers, } = useLxmfContext(); const insets = useSafeAreaInsets(); + const { mode } = useNetworkMode(); const { publicKey } = useWallet(); @@ -577,7 +586,7 @@ export default function MessagesScreen() { }, [seqStates]); const livePeers: Peer[] = useMemo(() => { - const dms = lxmfPeers.map(lxmfPeerToPeer); + const dms = lxmfPeers.map(p => lxmfPeerToPeer(p, mode)); const groupPeers: Peer[] = groups.map(g => ({ handle: g.name, destHash: g.addrHex, @@ -592,7 +601,7 @@ export default function MessagesScreen() { nameKnown: true, })); return [...groupPeers, ...dms]; - }, [lxmfPeers, groups]); + }, [lxmfPeers, groups, mode]); const activePeerObj = useMemo( () => livePeers.find(p => p.destHash === activePeerHex) ?? null, @@ -808,7 +817,7 @@ export default function MessagesScreen() { if (hash === activePeerHexRef.current) return; const peer = lxmfPeers.find(p => p.destHash === hash); const ident = getPeerIdentity(hash); - pickPeer(peer ? lxmfPeerToPeer(peer) : { + pickPeer(peer ? lxmfPeerToPeer(peer, mode) : { handle: ident.nameKnown ? ident.name : hash.slice(0, 8), hops: 0, iface: 'TCP', @@ -822,7 +831,7 @@ export default function MessagesScreen() { }); } return () => { messagesFocusedRef.current = false; }; - }, [lxmfPeers, pickPeer, getPeerIdentity])); + }, [lxmfPeers, pickPeer, getPeerIdentity, mode])); // Open a thread when navigated from another screen (e.g. Nodes DM button) useFocusEffect(useCallback(() => { @@ -834,7 +843,7 @@ export default function MessagesScreen() { handledDeepLinkRef.current = paramDestHash; const lxmfPeer = lxmfPeers.find(p => p.destHash === paramDestHash); const ident = getPeerIdentity(paramDestHash); - pickPeer(lxmfPeer ? lxmfPeerToPeer(lxmfPeer) : { + pickPeer(lxmfPeer ? lxmfPeerToPeer(lxmfPeer, mode) : { handle: ident.nameKnown ? ident.name : (paramHandle || paramDestHash.slice(0, 8)), hops: 0, iface: 'TCP', @@ -846,7 +855,7 @@ export default function MessagesScreen() { destHash: paramDestHash, nameKnown: ident.nameKnown, }); - }, [paramDestHash, paramHandle, lxmfPeers, pickPeer, getPeerIdentity])); + }, [paramDestHash, paramHandle, lxmfPeers, pickPeer, getPeerIdentity, mode])); return ( From 6015993674d3acc67d9b0dfb9284f0051fa5dd96 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 09:26:33 -0800 Subject: [PATCH 2/2] fix(nodes): LAST KNOWN badge + real reachable count when off-grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MeshMap header: '● LIVE' -> muted '◌ LAST KNOWN' when no internet route (both inline + fullscreen headers); hub topology is a snapshot - map nodes/rows: unreachable hub peers dim (online=false, signal 2) while BLE/RNode peers stay bright - beacon registry: 'reachable' stat now uses isPeerReachable so the 'Reachable-peer count is real' disclaimer is actually true --- .../components/nodes/BeaconRegistry.tsx | 8 +++++--- mobile_app/components/nodes/MeshMap.tsx | 14 +++++++++++--- mobile_app/screens/NodesScreen.tsx | 19 ++++++++++++------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/mobile_app/components/nodes/BeaconRegistry.tsx b/mobile_app/components/nodes/BeaconRegistry.tsx index c6d76d6..623a3f1 100644 --- a/mobile_app/components/nodes/BeaconRegistry.tsx +++ b/mobile_app/components/nodes/BeaconRegistry.tsx @@ -5,7 +5,7 @@ import { fontFamily, useTheme } from '@/theme'; import { useGlass } from '@/hooks/useGlass'; import { Pill } from '@/components/ui/Pill'; import { SolanaIcon } from '@/components/onboarding/SolanaIcon'; -import { useLxmfContext } from '@/context/LxmfContext'; +import { isPeerReachable, useLxmfContext } from '@/context/LxmfContext'; import { useNetworkMode } from '@/src/hooks/useNetworkMode'; @@ -19,10 +19,12 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini const glass = useGlass(); const softGlass = useGlass('soft'); const { isBeacon, setBeaconMode, peers } = useLxmfContext(); + const { mode: networkMode } = useNetworkMode(); // peers already includes beacon-nodes via mergeBeacon() in LxmfContext — // counting lxmf.beacons separately would double-count them (QA-55). - const reachableCount = peers.filter(p => p.online).length; - const { mode: networkMode } = useNetworkMode(); + // isPeerReachable, not p.online: the disclaimer below promises this count is + // real, and a stale hub announce is not reachable without an internet route. + const reachableCount = peers.filter(p => isPeerReachable(p, networkMode)).length; const hasInternet = networkMode === 'online'; const active = isBeacon; diff --git a/mobile_app/components/nodes/MeshMap.tsx b/mobile_app/components/nodes/MeshMap.tsx index f8ea2f1..78556e9 100644 --- a/mobile_app/components/nodes/MeshMap.tsx +++ b/mobile_app/components/nodes/MeshMap.tsx @@ -36,6 +36,8 @@ interface Props { onSelect: (h: string | null) => void; syncing?: boolean; isAnnouncing?: boolean; + /** No internet route — hub-sourced topology is a snapshot, not live. */ + offGrid?: boolean; selStripBottom?: number; filterRow?: React.ReactNode; onExpandChange?: (expanded: boolean) => void; @@ -191,7 +193,7 @@ const PeerNode = memo(function PeerNode({ node: n, x, y, r, isSelected, ifcClr, }); // ── Component ───────────────────────────────────────────────────────────────── -export const MeshMap = memo(function MeshMap({ nodes, selected, onSelect, syncing, isAnnouncing, selStripBottom = 0, filterRow, onExpandChange, onDirectMessage }: Props) { +export const MeshMap = memo(function MeshMap({ nodes, selected, onSelect, syncing, isAnnouncing, offGrid, selStripBottom = 0, filterRow, onExpandChange, onDirectMessage }: Props) { const { colors } = useTheme(); const insets = useSafeAreaInsets(); const reduceMotion = useReducedMotion(); @@ -486,6 +488,12 @@ export const MeshMap = memo(function MeshMap({ nodes, selected, onSelect, syncin ) : null; + // Header status: only claim LIVE while an internet route exists — off-grid, + // hub-sourced nodes are a last-known snapshot (radio-local peers stay bright). + let status: { label: string; color: string } = { label: ' ● LIVE', color: colors.primary }; + if (syncing && nodes.length === 0) status = { label: ' ◌ SYNCING', color: colors.primary }; + else if (offGrid) status = { label: ' ◌ LAST KNOWN', color: colors.textTertiary }; + // ── Render ──────────────────────────────────────────────────────────────── return ( @@ -496,7 +504,7 @@ export const MeshMap = memo(function MeshMap({ nodes, selected, onSelect, syncin MESH TOPOLOGY {` · ${nodes.length} NODE${nodes.length === 1 ? '' : 'S'}`} - {syncing && nodes.length === 0 ? ' ◌ SYNCING' : ' ● LIVE'} + {status.label} @@ -536,7 +544,7 @@ export const MeshMap = memo(function MeshMap({ nodes, selected, onSelect, syncin ANONMESH TOPOLOGY {` · ${nodes.length} NODE${nodes.length === 1 ? '' : 'S'}`} - {syncing && nodes.length === 0 ? ' ◌ SYNCING' : ' ● LIVE'} + {status.label} {isAnnouncing && } diff --git a/mobile_app/screens/NodesScreen.tsx b/mobile_app/screens/NodesScreen.tsx index c8ae4ca..4569800 100644 --- a/mobile_app/screens/NodesScreen.tsx +++ b/mobile_app/screens/NodesScreen.tsx @@ -6,7 +6,9 @@ import { import { useFocusEffect, useRouter } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; import { fontFamily, fontSize, radii, spacing, useTheme } from '@/theme'; -import { useLxmfContext, type LxmfPeer } from '@/context/LxmfContext'; +import { isPeerReachable, useLxmfContext, type LxmfPeer } from '@/context/LxmfContext'; +import { useNetworkMode } from '@/src/hooks/useNetworkMode'; +import type { NetworkMode } from '@/src/infrastructure/network/types'; import { MeshMap } from '@/components/nodes/MeshMap'; import { formatAgo } from '@/utils/time'; import { BeaconRegistry } from '@/components/nodes/BeaconRegistry'; @@ -24,14 +26,16 @@ function viaToIface(via: LxmfPeer['via']): 'BLE' | 'TCP' | 'RNode' { return 'TCP'; } -function peerToMapNode(p: LxmfPeer): NodeData { +function peerToMapNode(p: LxmfPeer, mode: NetworkMode): NodeData { + // Off-grid, hub-sourced peers are stale announces, not live nodes — dim them. + const reachable = isPeerReachable(p, mode); return { handle: p.displayName.slice(0, 16), hops: p.hops, iface: viaToIface(p.via), - signal: p.online ? 4 : 2, + signal: reachable ? 4 : 2, latency: '—', - online: p.online, + online: reachable, weak: false, destHash: p.destHash, beacon: p.isBeaconNode, @@ -41,6 +45,7 @@ function peerToMapNode(p: LxmfPeer): NodeData { export default function NodesScreen() { const { colors } = useTheme(); const { isRunning, isNativeAvailable, isAnnouncing, bleActive, peers, startBLE } = useLxmfContext(); + const { mode } = useNetworkMode(); const router = useRouter(); @@ -87,8 +92,8 @@ export default function NodesScreen() { // wouldn't look empty. That was a present-tense lie about the live mesh. // Now we return [] and let the empty-state below speak for itself. const meshNodes = useMemo( - () => isRunning ? peers.map(peerToMapNode) : [], - [peers, isRunning], + () => isRunning ? peers.map(p => peerToMapNode(p, mode)) : [], + [peers, isRunning, mode], ); const bleBlocked = !bleActive && (blePerm === 'denied' || blePerm === 'never_ask_again'); @@ -141,7 +146,7 @@ export default function NodesScreen() { {/* Map with filter chips overlaid at bottom */} - + {meshNodes.length === 0 && (