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
17 changes: 10 additions & 7 deletions mobile_app/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -157,13 +158,15 @@ export default function RootLayout() {
<GestureHandlerRootView style={R.gestureRoot}>
<ThemeProvider>
<LxmfProvider>
<WalletProvider autoInitialize>
<WalletBalanceProvider>
<HideBalanceProvider>
<AppShell />
</HideBalanceProvider>
</WalletBalanceProvider>
</WalletProvider>
<NetworkModeProvider>
<WalletProvider autoInitialize>
<WalletBalanceProvider>
<HideBalanceProvider>
<AppShell />
</HideBalanceProvider>
</WalletBalanceProvider>
</WalletProvider>
</NetworkModeProvider>
</LxmfProvider>
</ThemeProvider>
</GestureHandlerRootView>
Expand Down
8 changes: 1 addition & 7 deletions mobile_app/components/nodes/PendingCosigns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)}`;
}
Expand Down
3 changes: 3 additions & 0 deletions mobile_app/components/wallet/SendPanel.tsx
Original file line number Diff line number Diff line change
@@ -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
// <PreviewedActions>. 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';
Expand Down
3 changes: 3 additions & 0 deletions mobile_app/components/wallet/SwapPanel.tsx
Original file line number Diff line number Diff line change
@@ -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
// <PreviewedActions>. 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';
Expand Down
3 changes: 3 additions & 0 deletions mobile_app/components/wallet/WalletTabs.tsx
Original file line number Diff line number Diff line change
@@ -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
// <PreviewedActions>. 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';
Expand Down
3 changes: 3 additions & 0 deletions mobile_app/components/wallet/YieldPanel.tsx
Original file line number Diff line number Diff line change
@@ -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
// <PreviewedActions>. 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';
Expand Down
8 changes: 4 additions & 4 deletions mobile_app/components/wallet/index.ts
Original file line number Diff line number Diff line change
@@ -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 <PreviewBadge>. See AUDIT A6 + ROADMAP § 0.A.8.
18 changes: 1 addition & 17 deletions mobile_app/context/LxmfContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
140 changes: 140 additions & 0 deletions mobile_app/context/NetworkModeContext.tsx
Original file line number Diff line number Diff line change
@@ -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<NetworkState | undefined>(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<MeshRpcAdapter | null>(null);
const adapter = useMemo<IRpcAdapter>(() => {
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<NetworkState>(
() => ({ mode, adapter, relayHash: adapter.relayHash }),
[mode, adapter],
);

return (
<NetworkModeContext.Provider value={value}>{children}</NetworkModeContext.Provider>
);
}

export function useNetworkMode(): NetworkState {
const ctx = useContext(NetworkModeContext);
if (!ctx) {
throw new Error("useNetworkMode must be used within NetworkModeProvider");
}
return ctx;
}
13 changes: 1 addition & 12 deletions mobile_app/hooks/useMessageNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 1 addition & 12 deletions mobile_app/screens/MessagesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -77,18 +78,6 @@ function renderMsg(m: AnyMsg, getSendState: GetSendState): React.ReactElement {
return <MessageBubble key={m.id} m={chat} sendState={getSendState(m.id)} />;
}

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+/';
Expand Down
9 changes: 1 addition & 8 deletions mobile_app/screens/WalletScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand All @@ -41,14 +42,6 @@ const TOKEN_COLOR: Record<string, string> = {
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 }) {
Expand Down
Loading