Skip to content
Merged
41 changes: 27 additions & 14 deletions mobile_app/components/nodes/BeaconRegistry.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { memo, useState, useRef, useCallback, useEffect } from 'react';
import React, { memo, useState, useRef, useCallback } from 'react';
import { Animated, KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { fontFamily, useTheme } from '@/theme';
Expand Down Expand Up @@ -38,15 +38,14 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini
const [stakeAmt, setStakeAmt] = useState(0.5);
const [rawAmt, setRawAmt] = useState('0.5');
const amtInputRef = useRef<TextInput>(null);
const autoActivatedRef = useRef(false);
const sheetAnim = useRef(new Animated.Value(0)).current;
const stakeAnim = useRef(new Animated.Value(0)).current;

useEffect(() => {
if (!hasInternet || active || autoActivatedRef.current) return;
autoActivatedRef.current = true;
setBeaconMode(true);
}, [hasInternet, active, setBeaconMode]);
// Auto-activate on first internet was removed per AUDIT T10 / ROADMAP § 0.B.3:
// beacon mode carries trust implications (relaying others' traffic) so the
// user has to opt in via the Register-as-Beacon control. hasInternet is still
// consumed for UI affordances.
void hasInternet;

const openModal = useCallback(() => {
setModal(true);
Expand All @@ -58,6 +57,11 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini
.start(() => setModal(false));
}, [sheetAnim]);

const handleRegister = useCallback(() => {
setBeaconMode(true);
dismiss();
}, [setBeaconMode, dismiss]);

const commitAmt = useCallback((v: number) => {
const n = Math.max(0.5, Number.parseFloat(Math.max(0.5, v).toFixed(1)));
setStakeAmt(n);
Expand Down Expand Up @@ -196,14 +200,23 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini
))}
</View>

{/* CTA disabled in preview — biometric signing flow isn't wired yet.
Beacon mode still auto-activates via the hasInternet effect above. */}
<View
style={[S.actionBtn, { backgroundColor: colors.surface2, borderWidth: 0.5, borderColor: colors.border, opacity: 0.6 }]}
pointerEvents="none"
{/* CTA flips the local beacon-mode flag. Stake + biometric co-sign
flow remains future work; the JS-side opt-in is the consent gate
today. */}
<Pressable
onPress={handleRegister}
accessibilityRole="button"
accessibilityLabel="Enable beacon mode"
style={({ pressed }) => [
S.actionBtn,
{
backgroundColor: colors.primary,
opacity: pressed ? 0.8 : 1,
},
]}
>
<Text style={[S.actionText, { color: colors.textTertiary }]}>Preview — not yet active</Text>
</View>
<Text style={[S.actionText, { color: '#08080A' }]}>Enable Beacon Mode</Text>
</Pressable>
</Animated.View>
</View>
</Modal>
Expand Down
15 changes: 4 additions & 11 deletions mobile_app/components/nodes/constants.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
type Iface = 'TCP' | 'BLE' | 'RNode';

export const NODES: { handle: string; hops: number; iface: Iface; signal: number; latency: string; beacon?: boolean; online?: boolean; weak?: boolean }[] = [
{ handle: '@beacon_prime', hops: 0, iface: 'TCP', signal: 4, beacon: true, latency: '12ms' },
{ handle: '@node_a1b2', hops: 1, iface: 'TCP', signal: 4, latency: '48ms' },
{ handle: '@node_7f3a', hops: 3, iface: 'RNode', signal: 3, online: true, latency: '112ms' },
{ handle: '@node_c91d', hops: 2, iface: 'BLE', signal: 3, latency: '89ms' },
{ handle: '@relay_e2f0', hops: 4, iface: 'RNode', signal: 2, latency: '340ms' },
{ handle: '@node_44ab', hops: 2, iface: 'BLE', signal: 3, latency: '76ms' },
{ handle: '@sensor_9812', hops: 5, iface: 'RNode', signal: 1, weak: true, latency: '612ms' },
];
// NODES fixture removed per AUDIT T7 / ROADMAP § 0.4. The radar populates from
// real peers; when the mesh isn't running NodesScreen renders an honest empty
// state instead of a 7-item fake-peer list. MAP_NODES / MAP_EDGES below are
// unrelated topology layout fixtures (currently unused).

export const MAP_W = 320;
export const MAP_H = 270;
Expand Down
27 changes: 26 additions & 1 deletion mobile_app/screens/MessagesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,28 @@ export default function MessagesScreen() {
clearTimeout(immediateTimers.current.get(seq));
immediateTimers.current.delete(seq);
setSeqStates(m => new Map(m).set(seq, state));

// Flip enc to true only on confirmed delivery — never before the native
// module reports messageDelivered. AUDIT T9.
if (state === 'delivered') {
let msgIdForSeq: number | null = null;
for (const [mid, s] of idToSeqRef.current) {
if (s === seq) { msgIdForSeq = mid; break; }
}
if (msgIdForSeq !== null) {
const mid = msgIdForSeq;
const flip = (m: AnyMsg): AnyMsg =>
m.id === mid && 'enc' in m ? { ...m, enc: true } : m;
// Update currently-rendered thread.
setMsgs(prev => prev.map(flip));
// Also update any cached non-active thread that holds the sent bubble,
// so reopening the conversation still shows the lock after delivery.
threadsRef.current.forEach((thread, peerHash) => {
if (!thread.some(m => m.id === mid)) return;
threadsRef.current.set(peerHash, thread.map(flip));
});
}
}
}, []);

// Incoming messages + queue state events
Expand Down Expand Up @@ -442,7 +464,10 @@ export default function MessagesScreen() {
const sendMsg = useCallback(async (text: string) => {
const now = new Date().toTimeString().slice(0, 8);
const msgId = nextId();
setMsgs(m => [...m, { id: msgId, from: 'me', me: true, time: now, text, enc: true }]);
// enc:false until the native module emits messageDelivered for this seq —
// a lock icon on a still-queued (or eventually failed) send is a false
// present-tense claim per AUDIT T9 / ROADMAP § 0.3.
setMsgs(m => [...m, { id: msgId, from: 'me', me: true, time: now, text, enc: false }]);
if (!activePeerHex) {
setMsgs(m => [...m, { id: nextId(), kind: 'sys' as const, text: 'no peer selected — open drawer and pick one' }]);
return;
Expand Down
21 changes: 19 additions & 2 deletions mobile_app/screens/NodesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { MeshMap } from '@/components/nodes/MeshMap';
import { formatAgo } from '@/utils/time';
import { BeaconRegistry } from '@/components/nodes/BeaconRegistry';
import { PulseDot } from '@/components/ui/PulseDot';
import { NODES, FILTERS } from '@/components/nodes/constants';
import { FILTERS } from '@/components/nodes/constants';
import type { NodeData, Filter } from '@/components/nodes/types';
import { requestBLEPermissions } from '@/src/utils/blePermissions';

Expand Down Expand Up @@ -75,11 +75,19 @@ export default function NodesScreen() {

// Stable node identity: only re-creates when peers change, not on timer ticks.
// MeshMap receives this — topology layout only runs when peer set actually changes.
//
// Pre-AUDIT T7: when !isRunning we returned a 7-item fixture so the radar
// 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) : NODES,
() => isRunning ? peers.map(peerToMapNode) : [],
[peers, isRunning],
);

const emptyStateCopy = isNativeAvailable
? 'Starting mesh…'
: 'Mesh unavailable on this device';

// Fast lookup by destHash for latency enrichment
const peerMap = useMemo(
() => new Map(peers.map(p => [p.destHash, p])),
Expand Down Expand Up @@ -121,6 +129,13 @@ 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} />
{meshNodes.length === 0 && (
<View pointerEvents="none" style={S.emptyState}>
<Text style={[S.emptyStateText, { color: colors.textTertiary }]}>
{emptyStateCopy}
</Text>
</View>
)}
{mapExpanded && <View style={S.filterOverlay}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={S.filterRow}>
{FILTERS.map(f => {
Expand Down Expand Up @@ -173,4 +188,6 @@ const S = StyleSheet.create({
chipText: { fontFamily: fontFamily.sansMd, fontSize: 10, letterSpacing: 2, textTransform: 'uppercase' },
chipCount: { fontFamily: fontFamily.sansMd, fontSize: 9, letterSpacing: 0.5, opacity: 0.8 },
bottomScroll: { paddingBottom: 24 },
emptyState: { position: 'absolute', left: 0, right: 0, top: 60, alignItems: 'center' },
emptyStateText:{ fontFamily: fontFamily.sansMd, fontSize: 11, letterSpacing: 1.5, textTransform: 'uppercase' },
});
3 changes: 2 additions & 1 deletion mobile_app/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ export default function SettingsScreen() {
<View style={{ flexDirection: 'row', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
<Pill label="CONNECTED" variant="primary" dot />
<Pill label="LoRa" variant="default" />
<Pill label="78% BATT" variant="default" />
{/* 78% BATT pill removed per AUDIT T11 / ROADMAP § 0.B.5
— no battery telemetry from the pairing API today. */}
</View>
</View>
</View>
Expand Down
76 changes: 45 additions & 31 deletions mobile_app/screens/WalletScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,40 +131,54 @@ const ACTIONS = [
function ActionTiles() {
const { colors } = useTheme();
const router = useRouter();
// Gate the Send tile at render-time so isolated-mode users can't walk three
// screens deep before the confirm-step error tells them no RPC route exists.
// ROADMAP § 2.4 / 02-UX P0 #6.
const { mode } = useNetworkMode();
const isolated = mode === 'isolated';
Comment on lines 131 to +138
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real concern at this branch's diff base (upstream/v3 c6738d6 — pre-context useNetworkMode). After PR #47 merged to v3, useNetworkMode is now a useContext shim — the second call here is just a context lookup, not a fresh NetInfo subscription or MeshRpcAdapter instance. Resolved by the merge order A → C → B → D.


return (
<View style={S.actionRow}>
{ACTIONS.map(a => (
<Pressable
key={a.id}
disabled={'soon' in a && a.soon}
onPress={() => {
if (!('route' in a)) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
router.push(a.route as never);
}}
style={({ pressed }) => {
const soon = 'soon' in a && a.soon;
const pressedOpacity = pressed ? 0.7 : 1;
const opacity = soon ? 0.4 : pressedOpacity;
return [S.tile, S.actionTile, {
backgroundColor: a.primary ? colors.primary : colors.surface2,
borderColor: a.primary ? colors.primary : colors.border,
opacity,
}];
}}
>
<View style={[S.actionIconWrap, { backgroundColor: a.primary ? 'rgba(0,0,0,0.15)' : colors.primarySubtle }]}>
<Feather name={a.icon} size={18} color={a.primary ? '#08080A' : colors.primary} />
</View>
<Text style={[S.actionLabel, { color: a.primary ? '#08080A' : colors.textSecondary }]}>
{a.label}
</Text>
{'soon' in a && a.soon && (
<Text style={[S.soonBadge, { color: colors.textTertiary }]}>SOON</Text>
)}
</Pressable>
))}
{ACTIONS.map(a => {
const soon = 'soon' in a && a.soon;
const sendDisabled = a.id === 'send' && isolated;
const disabled = soon || sendDisabled;
return (
<Pressable
key={a.id}
disabled={disabled}
onPress={() => {
if (!('route' in a)) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
router.push(a.route as never);
}}
style={({ pressed }) => {
const pressedOpacity = pressed ? 0.7 : 1;
const opacity = disabled ? 0.4 : pressedOpacity;
return [S.tile, S.actionTile, {
backgroundColor: a.primary ? colors.primary : colors.surface2,
borderColor: a.primary ? colors.primary : colors.border,
opacity,
}];
}}
>
<View style={[S.actionIconWrap, { backgroundColor: a.primary ? 'rgba(0,0,0,0.15)' : colors.primarySubtle }]}>
<Feather name={a.icon} size={18} color={a.primary ? '#08080A' : colors.primary} />
</View>
<Text style={[S.actionLabel, { color: a.primary ? '#08080A' : colors.textSecondary }]}>
{a.label}
</Text>
{soon && (
<Text style={[S.soonBadge, { color: colors.textTertiary }]}>SOON</Text>
)}
{sendDisabled && (
<Text style={[S.soonBadge, { color: a.primary ? '#08080A' : colors.textTertiary }]}>
NO ROUTE
</Text>
)}
</Pressable>
);
})}
</View>
);
}
Expand Down
Loading