diff --git a/mobile_app/app/_layout.tsx b/mobile_app/app/_layout.tsx index e38dc3af..0927e5ba 100644 --- a/mobile_app/app/_layout.tsx +++ b/mobile_app/app/_layout.tsx @@ -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'; @@ -157,13 +158,15 @@ export default function RootLayout() { - - - - - - - + + + + + + + + + diff --git a/mobile_app/components/nodes/PendingCosigns.tsx b/mobile_app/components/nodes/PendingCosigns.tsx index 0565391e..c4073fe7 100644 --- a/mobile_app/components/nodes/PendingCosigns.tsx +++ b/mobile_app/components/nodes/PendingCosigns.tsx @@ -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; @@ -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)}`; } diff --git a/mobile_app/components/wallet/SendPanel.tsx b/mobile_app/components/wallet/SendPanel.tsx index 484a74a9..72cab1e2 100644 --- a/mobile_app/components/wallet/SendPanel.tsx +++ b/mobile_app/components/wallet/SendPanel.tsx @@ -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 +// . 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'; diff --git a/mobile_app/components/wallet/SwapPanel.tsx b/mobile_app/components/wallet/SwapPanel.tsx index db24aebf..41e3ffb3 100644 --- a/mobile_app/components/wallet/SwapPanel.tsx +++ b/mobile_app/components/wallet/SwapPanel.tsx @@ -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 +// . 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'; diff --git a/mobile_app/components/wallet/WalletTabs.tsx b/mobile_app/components/wallet/WalletTabs.tsx index a319dd9c..231f5de8 100644 --- a/mobile_app/components/wallet/WalletTabs.tsx +++ b/mobile_app/components/wallet/WalletTabs.tsx @@ -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 +// . 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'; diff --git a/mobile_app/components/wallet/YieldPanel.tsx b/mobile_app/components/wallet/YieldPanel.tsx index de3384dd..a4c7626a 100644 --- a/mobile_app/components/wallet/YieldPanel.tsx +++ b/mobile_app/components/wallet/YieldPanel.tsx @@ -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 +// . 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'; diff --git a/mobile_app/components/wallet/index.ts b/mobile_app/components/wallet/index.ts index d4f87b45..c3a51ca6 100644 --- a/mobile_app/components/wallet/index.ts +++ b/mobile_app/components/wallet/index.ts @@ -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 . See AUDIT A6 + ROADMAP § 0.A.8. diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index 4d9f89c4..d3b3b9c4 100644 --- a/mobile_app/context/LxmfContext.tsx +++ b/mobile_app/context/LxmfContext.tsx @@ -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; @@ -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, diff --git a/mobile_app/context/NetworkModeContext.tsx b/mobile_app/context/NetworkModeContext.tsx new file mode 100644 index 00000000..c2d26293 --- /dev/null +++ b/mobile_app/context/NetworkModeContext.tsx @@ -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(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(null); + const adapter = useMemo(() => { + 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( + () => ({ mode, adapter, relayHash: adapter.relayHash }), + [mode, adapter], + ); + + return ( + {children} + ); +} + +export function useNetworkMode(): NetworkState { + const ctx = useContext(NetworkModeContext); + if (!ctx) { + throw new Error("useNetworkMode must be used within NetworkModeProvider"); + } + return ctx; +} diff --git a/mobile_app/hooks/useMessageNotifications.ts b/mobile_app/hooks/useMessageNotifications.ts index 5b979268..9b04de63 100644 --- a/mobile_app/hooks/useMessageNotifications.ts +++ b/mobile_app/hooks/useMessageNotifications.ts @@ -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'; @@ -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, diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index 85c60752..7f24c47c 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -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; @@ -77,18 +78,6 @@ function renderMsg(m: AnyMsg, getSendState: GetSendState): React.ReactElement { return ; } -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+/'; diff --git a/mobile_app/screens/WalletScreen.tsx b/mobile_app/screens/WalletScreen.tsx index 7aed8399..09d44858 100644 --- a/mobile_app/screens/WalletScreen.tsx +++ b/mobile_app/screens/WalletScreen.tsx @@ -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 ─────────────────────────────────────────────────────────────────── @@ -41,14 +42,6 @@ const TOKEN_COLOR: Record = { 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 }) { diff --git a/mobile_app/src/hooks/useNetworkMode.ts b/mobile_app/src/hooks/useNetworkMode.ts index 684b7b31..358ad8f3 100644 --- a/mobile_app/src/hooks/useNetworkMode.ts +++ b/mobile_app/src/hooks/useNetworkMode.ts @@ -1,110 +1,5 @@ -import "@/polyfills"; - -import NetInfo from '@react-native-community/netinfo'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useLxmfContext } from '@/context/LxmfContext'; -import { solanaConnection } from '@/src/services/sendTransaction'; -import { DirectRpcAdapter } from '../infrastructure/network/DirectRpcAdapter'; -import { IsolatedRpcAdapter } from '../infrastructure/network/IsolatedRpcAdapter'; -import { MeshRpcAdapter } from '../infrastructure/network/MeshRpcAdapter'; -import type { IRpcAdapter, NetworkMode } from '../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; -} - -export function useNetworkMode(): NetworkState { - 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)); - }); - // Fetch once on mount so initial state is correct before first event. - 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 refs — recreate only when mode or relay changes. - const meshAdapterRef = useRef(null); - const adapter = useMemo(() => { - 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 and rpcResponse events to the active MeshRpcAdapter. - 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]); - - return { mode, adapter, relayHash: adapter.relayHash }; -} +// useNetworkMode lives in `context/NetworkModeContext` so the MeshRpcAdapter +// + NetInfo subscription stay singletons across the app. This module +// re-exports the hook + types so existing import paths keep working. +export { useNetworkMode, NetworkModeProvider } from "@/context/NetworkModeContext"; +export type { NetworkState } from "@/context/NetworkModeContext"; diff --git a/mobile_app/src/hooks/useWalletBalance.tsx b/mobile_app/src/hooks/useWalletBalance.tsx index 336d110f..ec355347 100644 --- a/mobile_app/src/hooks/useWalletBalance.tsx +++ b/mobile_app/src/hooks/useWalletBalance.tsx @@ -2,7 +2,7 @@ import React, { createContext, ReactNode, useCallback, useContext, useEffect, us import { useWallet } from "@/context/WalletContext"; import { useNetworkMode } from "@/src/hooks/useNetworkMode"; -import { solanaConnection } from "@/src/services/sendTransaction"; +import { solanaConnection } from "@/src/infrastructure/network/connection"; import { ActivityEntry, SOL_DECIMALS, diff --git a/mobile_app/src/infrastructure/network/connection.ts b/mobile_app/src/infrastructure/network/connection.ts new file mode 100644 index 00000000..868eb33f --- /dev/null +++ b/mobile_app/src/infrastructure/network/connection.ts @@ -0,0 +1,15 @@ +import "@/polyfills"; + +import { Connection } from "@solana/web3.js"; + +// Devnet-only for safety. Mainnet wiring is a deliberate future decision — +// we don't want mainnet funds going out via a dev build. +// +// EXPO_PUBLIC_SOLANA_RPC lets teams point at a dedicated devnet endpoint +// (Helius / QuickNode / Triton free tier) to avoid the public endpoint's +// 429 rate-limits. Falls back to the public endpoint when unset so cloning +// the repo "just works". +const DEFAULT_DEVNET_RPC = "https://api.devnet.solana.com"; +const RPC_URL = process.env.EXPO_PUBLIC_SOLANA_RPC || DEFAULT_DEVNET_RPC; + +export const solanaConnection = new Connection(RPC_URL, "confirmed"); diff --git a/mobile_app/src/services/sendTransaction.ts b/mobile_app/src/services/sendTransaction.ts index 3c6b13fb..cff9a62a 100644 --- a/mobile_app/src/services/sendTransaction.ts +++ b/mobile_app/src/services/sendTransaction.ts @@ -1,7 +1,6 @@ import "@/polyfills"; import { - Connection, Keypair, PublicKey, SystemProgram, @@ -16,6 +15,7 @@ import { transact } from "@solana-mobile/mobile-wallet-adapter-protocol-web3js"; import { Buffer } from "buffer"; import type { IRpcAdapter } from "@/src/infrastructure/network"; +import { solanaConnection } from "@/src/infrastructure/network/connection"; import type { IWalletAdapter } from "@/src/infrastructure/wallet"; import { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; import { assertSendableSplProgram } from "@/src/services/walletData"; @@ -29,17 +29,10 @@ const APP_IDENTITY = { icon: "/favicon.ico", }; -// Devnet-only for safety. Mainnet wiring is a deliberate future -// decision — we don't want mainnet funds going out via a dev build. -// -// EXPO_PUBLIC_SOLANA_RPC lets teams point at a dedicated devnet -// endpoint (Helius / QuickNode / Triton free tier) to avoid the -// public endpoint's 429 rate-limits. Falls back to the public -// endpoint when unset so cloning the repo "just works". -const DEFAULT_DEVNET_RPC = "https://api.devnet.solana.com"; -const RPC_URL = process.env.EXPO_PUBLIC_SOLANA_RPC || DEFAULT_DEVNET_RPC; - -export const solanaConnection = new Connection(RPC_URL, "confirmed"); +// Re-export so existing consumers (`@/src/services/sendTransaction`) keep +// working without import churn. The singleton lives in +// `src/infrastructure/network/connection.ts`. +export { solanaConnection }; export interface SendSolParams { walletAdapter: IWalletAdapter; diff --git a/mobile_app/src/utils/relTime.ts b/mobile_app/src/utils/relTime.ts new file mode 100644 index 00000000..4ff85d54 --- /dev/null +++ b/mobile_app/src/utils/relTime.ts @@ -0,0 +1,12 @@ +/** + * Format a unix epoch in ms as "5m ago" / "2h ago" / "3d ago". + * + * Returns "just now" under 60s. + */ +export function relTime(ms: number): string { + 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`; +} diff --git a/mobile_app/src/utils/sliceNewEvents.ts b/mobile_app/src/utils/sliceNewEvents.ts new file mode 100644 index 00000000..48aeebc1 --- /dev/null +++ b/mobile_app/src/utils/sliceNewEvents.ts @@ -0,0 +1,27 @@ +import type { LxmfEvent } from "@magicred-1/react-native-lxmf"; + +/** + * The LXMF event log is prepended newest-first and capped at 200 entries. Once + * the cap is hit length stops growing, so callers track the previous head by + * reference to detect new prepended events. + * + * Three semantics for the return value: + * - length grew → return the prefix slice that's new + * - length capped but head moved → return the prefix up to the previous head + * (or everything if the previous head fell off the end) + * - head unchanged → return [] + */ +export 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 []; +}