Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion mobile_app/components/messages/PeersDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -373,7 +377,7 @@ export const PeersDrawer = memo(function PeersDrawer({
<View style={S.titleRow}>
<Text style={[S.title, { color: colors.textPrimary }]}>messages</Text>
<Text style={[S.onlineCount, { color: colors.textTertiary }]}>
{online} online
{mode === 'online' ? `${online} online` : `off-grid · ${online} reachable`}
</Text>
</View>
</View>
Expand Down
8 changes: 5 additions & 3 deletions mobile_app/components/nodes/BeaconRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand All @@ -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;
Expand Down
14 changes: 11 additions & 3 deletions mobile_app/components/nodes/MeshMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -486,6 +488,12 @@ export const MeshMap = memo(function MeshMap({ nodes, selected, onSelect, syncin
</View>
) : 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 (
<View style={[S.outer, { borderColor: colors.border, backgroundColor: colors.surface0 }]}>
Expand All @@ -496,7 +504,7 @@ export const MeshMap = memo(function MeshMap({ nodes, selected, onSelect, syncin
<Text accessibilityRole="header" style={[S.headerLabel, { color: colors.textTertiary }]}>
MESH TOPOLOGY
<Text style={{ color: colors.textTertiary }}>{` · ${nodes.length} NODE${nodes.length === 1 ? '' : 'S'}`}</Text>
<Text style={{ color: colors.primary }}>{syncing && nodes.length === 0 ? ' ◌ SYNCING' : ' ● LIVE'}</Text>
<Text style={{ color: status.color }}>{status.label}</Text>
</Text>
<Feather name={expanded ? 'chevron-up' : 'chevron-down'} size={14} color={colors.textSecondary} />
</View>
Expand Down Expand Up @@ -536,7 +544,7 @@ export const MeshMap = memo(function MeshMap({ nodes, selected, onSelect, syncin
<Text style={[S.headerLabel, { flex: 1, color: colors.textTertiary }]}>
ANONMESH TOPOLOGY
<Text style={{ color: colors.textTertiary }}>{` · ${nodes.length} NODE${nodes.length === 1 ? '' : 'S'}`}</Text>
<Text style={{ color: colors.primary }}>{syncing && nodes.length === 0 ? ' ◌ SYNCING' : ' ● LIVE'}</Text>
<Text style={{ color: status.color }}>{status.label}</Text>
</Text>
{isAnnouncing && <PulseDot size={5} />}
<Pressable onPress={exitFullscreen} style={[S.iconBtn, { paddingRight: 14 }]} hitSlop={8}>
Expand Down
11 changes: 11 additions & 0 deletions mobile_app/context/LxmfContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 19 additions & 10 deletions mobile_app/screens/MessagesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -352,6 +360,7 @@ export default function MessagesScreen() {
groups, createGroup, leaveGroup, getGroupMembers,
} = useLxmfContext();
const insets = useSafeAreaInsets();
const { mode } = useNetworkMode();

const { publicKey } = useWallet();

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -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(() => {
Expand All @@ -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',
Expand All @@ -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 (
<View style={[S.root, { backgroundColor: colors.background }]}>
Expand Down
19 changes: 12 additions & 7 deletions mobile_app/screens/NodesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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();

Expand Down Expand Up @@ -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<NodeData[]>(
() => 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');
Expand Down Expand Up @@ -141,7 +146,7 @@ export default function NodesScreen() {

{/* Map with filter chips overlaid at bottom */}
<View style={S.mapWrap}>
<MeshMap nodes={filtered} selected={selectedHandle} onSelect={setSelectedHandle} syncing={loading} isAnnouncing={isAnnouncing} selStripBottom={36} onExpandChange={setMapExpanded} onDirectMessage={handleDirectMessage} />
<MeshMap nodes={filtered} selected={selectedHandle} onSelect={setSelectedHandle} syncing={loading} isAnnouncing={isAnnouncing} offGrid={mode !== 'online'} selStripBottom={36} onExpandChange={setMapExpanded} onDirectMessage={handleDirectMessage} />
{meshNodes.length === 0 && (
<View pointerEvents="none" style={S.emptyState}>
<Text style={[S.emptyStateText, { color: colors.textTertiary }]}>
Expand Down
Loading