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/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/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 (
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 && (