From f3b21073db4a92c4d49b36afc0f515966332ca85 Mon Sep 17 00:00:00 2001 From: Hunter F <33706074+epicexcelsior@users.noreply.github.com> Date: Sun, 24 May 2026 17:16:05 -0800 Subject: [PATCH 01/94] build(babel): babel.config.js + explicit class-static-block plugin (#86) Adds babel.config.js enabling @babel/plugin-transform-class-static-block (required by three.js in the mesh-map renderer) and declares the plugin as a direct devDependency so resolution is guaranteed on fresh installs. Fixes the silent iOS bundling failure (Metro HTTP 500) that left dev-clients running stale embedded JS. --- mobile_app/babel.config.js | 13 +++++++++++++ mobile_app/package-lock.json | 1 + mobile_app/package.json | 1 + 3 files changed, 15 insertions(+) create mode 100644 mobile_app/babel.config.js diff --git a/mobile_app/babel.config.js b/mobile_app/babel.config.js new file mode 100644 index 0000000..f3c5f43 --- /dev/null +++ b/mobile_app/babel.config.js @@ -0,0 +1,13 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + // three.js (pulled in by the mesh-map renderer) uses ES2022 class + // static blocks. babel-preset-expo's default target list doesn't + // include the transform, so iOS bundling fails with "Static class + // blocks are not enabled" before the dev-client can fetch anything. + '@babel/plugin-transform-class-static-block', + ], + }; +}; diff --git a/mobile_app/package-lock.json b/mobile_app/package-lock.json index 2df5c4a..4ee904e 100644 --- a/mobile_app/package-lock.json +++ b/mobile_app/package-lock.json @@ -68,6 +68,7 @@ "three": "^0.184.0" }, "devDependencies": { + "@babel/plugin-transform-class-static-block": "^7.28.6", "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", diff --git a/mobile_app/package.json b/mobile_app/package.json index 24c1e9a..63f07bc 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -77,6 +77,7 @@ "three": "^0.184.0" }, "devDependencies": { + "@babel/plugin-transform-class-static-block": "^7.28.6", "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", From 8d590ed2cd624e6300dad45a697b715c2df66ace Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 19 May 2026 01:34:15 -0800 Subject: [PATCH 02/94] fix(qr-scan): gate CameraView on permission + surface unrecognized QR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related iOS bugs observed on a personal-team dev build: 1. CameraView mounted before permission resolved → black preview that never recovers until app restart. On iOS the component caches its permission state at mount; flipping null→granted at runtime doesn't re-init the preview. Gate the mount on `permission.granted === true` so the conditional flip causes a fresh mount with permission in place. 2. PeersDrawer's QR onResult silently dropped any QR that wasn't a plain LXMF hash — group QRs, Solana addresses, malformed/empty payloads all closed the modal with no feedback, leaving users thinking "scanner is broken." Surface explicit alerts: - lxmf-group → "looks like a channel QR, use Join channel" - solana → "wallet address, use Send screen" - unknown → show first 64 chars of raw payload so user can debug Also adds a __DEV__ console.log of the parsed result so we can adb logcat-trace exactly what came out of the scanner. --- .../components/messages/PeersDrawer.tsx | 19 +++++++++++++++++++ .../components/messages/QRScannerModal.tsx | 18 ++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/mobile_app/components/messages/PeersDrawer.tsx b/mobile_app/components/messages/PeersDrawer.tsx index cb8e4f6..5731035 100644 --- a/mobile_app/components/messages/PeersDrawer.tsx +++ b/mobile_app/components/messages/PeersDrawer.tsx @@ -323,8 +323,27 @@ export const PeersDrawer = memo(function PeersDrawer({ onClose={() => setScannerOpen(false)} onResult={result => { setScannerOpen(false); + if (__DEV__) console.log('[QR scan/PeersDrawer]', JSON.stringify(result)); if (result.type === 'lxmf') { onNewHash?.(result.hash); + } else if (result.type === 'lxmf-group') { + // Group QR scanned in peer-finder flow — route to join modal instead. + Alert.alert( + 'Looks like a channel QR', + 'Open "Join channel" and scan the same QR to join the channel.', + ); + } else if (result.type === 'solana') { + Alert.alert( + 'Solana address scanned', + 'This is a wallet address. Use the Send screen to send funds.', + ); + } else { + // unknown — surface the raw payload so the user can debug what they scanned + const preview = result.raw.length > 64 ? result.raw.slice(0, 64) + '…' : result.raw; + Alert.alert( + 'Unrecognized QR', + `Not a peer or channel QR. Scanned content:\n\n${preview}`, + ); } }} /> diff --git a/mobile_app/components/messages/QRScannerModal.tsx b/mobile_app/components/messages/QRScannerModal.tsx index 697639e..be5ce2f 100644 --- a/mobile_app/components/messages/QRScannerModal.tsx +++ b/mobile_app/components/messages/QRScannerModal.tsx @@ -117,7 +117,14 @@ export function QRScannerModal({ visible, onResult, onClose }: Props) { if (!visible) return null; - const denied = permission && !permission.granted && !permission.canAskAgain; + const denied = permission && !permission.granted && !permission.canAskAgain; + // Mount CameraView ONLY after permission is explicitly granted. On iOS the + // CameraView component caches its initial permission state at mount time — + // if we render it before the OS prompt resolves, the preview stays black + // even after the user taps "Allow," and an app restart is needed to recover. + // Gating on permission.granted means the flip from null→granted re-mounts + // CameraView fresh with the new permission in place. + const granted = permission?.granted === true; return ( @@ -129,13 +136,20 @@ export function QRScannerModal({ visible, onResult, onClose }: Props) { Camera permission denied.{'\n'}Enable it in Settings. - ) : ( + ) : granted ? ( + ) : ( + + + + Requesting camera access… + + )} {/* Viewfinder */} From 590ae74b92ca3e2746785189125d6d33d815a23f Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 20 May 2026 04:14:22 -0800 Subject: [PATCH 03/94] build(deps): add expo-audio as expo-camera 17 peer dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expo-camera@17 split audio recording into a separate package (expo-audio). iOS native side hard-links the ExpoAudio module even when the JS-level use is barcode-scanning only, so the dev-client build fails with "Cannot find native module expoAudio" without the peer dep installed. `npx expo install expo-audio` added 1.1.1 to deps and registered the config plugin in app.json. No source code changes — this is pure native bridge availability. --- mobile_app/app.json | 3 ++- mobile_app/package-lock.json | 14 ++++++++++++++ mobile_app/package.json | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mobile_app/app.json b/mobile_app/app.json index 32110b2..16a957b 100644 --- a/mobile_app/app.json +++ b/mobile_app/app.json @@ -105,7 +105,8 @@ ], "@magicred-1/react-native-lxmf", "./plugins/withAndroidForegroundService", - "expo-web-browser" + "expo-web-browser", + "expo-audio" ], "experiments": { "typedRoutes": true, diff --git a/mobile_app/package-lock.json b/mobile_app/package-lock.json index 4ee904e..e5110c7 100644 --- a/mobile_app/package-lock.json +++ b/mobile_app/package-lock.json @@ -29,6 +29,7 @@ "bs58": "^6.0.0", "buffer": "^6.0.3", "expo": "~54.0.34", + "expo-audio": "~1.1.1", "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", @@ -7607,6 +7608,7 @@ "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==", "license": "MIT", + "peer": true, "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.13" @@ -7617,6 +7619,18 @@ "react-native": "*" } }, + "node_modules/expo-audio": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-1.1.1.tgz", + "integrity": "sha512-CPCpJ+0AEHdzWROc0f00Zh6e+irLSl2ALos/LPvxEeIcJw1APfBa4DuHPkL4CQCWsVe7EnUjFpdwpqsEUWcP0g==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-camera": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", diff --git a/mobile_app/package.json b/mobile_app/package.json index 63f07bc..95852eb 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -38,6 +38,7 @@ "bs58": "^6.0.0", "buffer": "^6.0.3", "expo": "~54.0.34", + "expo-audio": "~1.1.1", "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", From 36dfd23119a33ff94d1b481d85015dcdc8bac56c Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Mon, 25 May 2026 01:25:25 -0800 Subject: [PATCH 04/94] refactor(scanner): present QR scanner as a route, not a nested modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QRScannerModal was a free-floating . Rendered inside the join-channel sheet (itself a ) it stacked two native iOS windows — black camera preview, and corrupted touch handling on dismiss (frozen screen, camera never released). A single modal (Send, peers drawer) worked; only nesting broke. - app/scan.tsx route + scan() promise API (src/services/qrScan), usable identically from any screen or modal; one camera/permission lifecycle; re-entrancy-guarded so overlapping calls can't stack routes. - Convert the join-channel sheet to app/join-channel.tsx (transparentModal owning its slide, like receive) so the scanner composes over it natively — no nested-modal conflict. - Update RecipientPicker, PeersDrawer, and the join flow to await scan(). Delete QRScannerModal and JoinGroupModal. --- mobile_app/app/_layout.tsx | 8 + mobile_app/app/join-channel.tsx | 236 ++++++++++++++++++ mobile_app/app/scan.tsx | 161 ++++++++++++ .../components/messages/JoinGroupModal.tsx | 232 ----------------- .../components/messages/PeersDrawer.tsx | 63 +++-- .../components/messages/QRScannerModal.tsx | 227 ----------------- .../components/send/RecipientPicker.tsx | 43 ++-- mobile_app/screens/MessagesScreen.tsx | 15 +- mobile_app/src/services/qrScan.ts | 103 ++++++++ 9 files changed, 560 insertions(+), 528 deletions(-) create mode 100644 mobile_app/app/join-channel.tsx create mode 100644 mobile_app/app/scan.tsx delete mode 100644 mobile_app/components/messages/JoinGroupModal.tsx delete mode 100644 mobile_app/components/messages/QRScannerModal.tsx create mode 100644 mobile_app/src/services/qrScan.ts diff --git a/mobile_app/app/_layout.tsx b/mobile_app/app/_layout.tsx index 838e201..61f7db8 100644 --- a/mobile_app/app/_layout.tsx +++ b/mobile_app/app/_layout.tsx @@ -112,6 +112,14 @@ function AppShell() { + {/* QR scanner — a route, not a , so it presents above + everything (including bottom-sheet modals) without stacking + native windows. Driven by scan() in src/services/qrScan. */} + + {/* Join channel — a transparentModal route that owns its slide + animation (same pattern as receive), so the scanner route can + compose over it without a nested- conflict. */} + diff --git a/mobile_app/app/join-channel.tsx b/mobile_app/app/join-channel.tsx new file mode 100644 index 0000000..6c8f2bd --- /dev/null +++ b/mobile_app/app/join-channel.tsx @@ -0,0 +1,236 @@ +import { router } from 'expo-router'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Animated, BackHandler, View, Text, TextInput, Pressable, + StyleSheet, ActivityIndicator, Platform, Keyboard, +} from 'react-native'; +import { Feather } from '@expo/vector-icons'; +import { fontFamily, useTheme } from '@/theme'; +import { useGlass } from '@/hooks/useGlass'; +import { useLxmfContext } from '@/context/LxmfContext'; +import { scan } from '@/src/services/qrScan'; + +const ADDR_RE = /^[0-9a-fA-F]{32}$/; +const KEY_RE = /^[0-9a-fA-F]{32}$/; + +// Join-channel as a route, not a . It owns its slide-up/down animation +// exactly like app/receive.tsx: presentation 'transparentModal' + animation +// 'none' (set in _layout) means there's no native modal motion to fight. +// Being a route is what lets the QR scanner route (scan()) compose cleanly on +// top — pushing a route over a route never conflicts, so the old "hide the +// sheet while scanning" workaround is gone. The screen behind stays mounted +// underneath; popping the scanner reveals this sheet instantly. +export default function JoinChannelScreen() { + const { colors } = useTheme(); + const baseGlass = useGlass(); + const softGlass = useGlass('soft'); + const { joinGroup } = useLxmfContext(); + + const [addrHex, setAddrHex] = useState(''); + const [keyHex, setKeyHex] = useState(''); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const sheetAnim = useRef(new Animated.Value(0)).current; + const kbOffset = useRef(new Animated.Value(0)).current; + const closingRef = useRef(false); + + // Enter: slide up on mount. + useEffect(() => { + Animated.spring(sheetAnim, { toValue: 1, useNativeDriver: true, bounciness: 4 }).start(); + }, [sheetAnim]); + + // Keyboard avoidance — lift the sheet by the keyboard height. + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const show = Keyboard.addListener(showEvent, e => { + Animated.timing(kbOffset, { + toValue: e.endCoordinates.height, + duration: Platform.OS === 'ios' ? e.duration : 150, + useNativeDriver: false, + }).start(); + }); + const hide = Keyboard.addListener(hideEvent, e => { + Animated.timing(kbOffset, { + toValue: 0, + duration: Platform.OS === 'ios' ? e.duration : 150, + useNativeDriver: false, + }).start(); + }); + return () => { show.remove(); hide.remove(); }; + }, [kbOffset]); + + // Exit: slide down, then pop the route. animation:'none' on the route means + // router.back() is instant — the slide-down is the only motion the user sees. + const dismiss = useCallback(() => { + if (closingRef.current) return; + closingRef.current = true; + Keyboard.dismiss(); + Animated.timing(sheetAnim, { toValue: 0, duration: 220, useNativeDriver: true }).start(() => { + if (router.canGoBack()) router.back(); + }); + }, [sheetAnim]); + + // Android hardware back → animated dismiss (not an instant pop). + useEffect(() => { + const sub = BackHandler.addEventListener('hardwareBackPress', () => { dismiss(); return true; }); + return () => sub.remove(); + }, [dismiss]); + + async function handleScan() { + const r = await scan(); + if (!r) return; + if (r.type === 'lxmf-group') { + setAddrHex(r.addrHex); + setKeyHex(r.keyHex); + const nm = r.name; + if (nm) setName(prev => (prev ? prev : nm)); + setError(null); + } else if (r.type === 'lxmf') { + setAddrHex(r.hash); + setError(null); + } + } + + const addrOk = ADDR_RE.test(addrHex.trim()); + const keyOk = KEY_RE.test(keyHex.trim()); + const canJoin = addrOk && keyOk && !loading; + + async function handleJoin() { + if (!canJoin) return; + setError(null); + setLoading(true); + try { + const ok = await joinGroup(addrHex.trim(), keyHex.trim(), name.trim() || undefined); + if (ok) { dismiss(); } + else { setError('could not join — check address and key'); } + } catch { + setError('join failed — try again'); + } finally { + setLoading(false); + } + } + + const sheetY = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [500, 0], extrapolate: 'clamp' }); + const overlayOp = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }); + + return ( + + + + + + + + + + + + CHANNELS + join channel + + + + + + + + {/* Prominent QR scan button */} + + + SCAN QR CODE + + + + + OR ENTER MANUALLY + + + + CHANNEL ADDRESS + + { setAddrHex(t); setError(null); }} + autoCapitalize="none" + autoCorrect={false} + /> + + + ENCRYPTION KEY + + { setKeyHex(t); setError(null); }} + autoCapitalize="none" + autoCorrect={false} + /> + + + + NICKNAME{' '}(optional) + + + + + + + {!!error && ( + {error} + )} + + + {loading + ? + : JOIN CHANNEL + } + + + + + + ); +} + +const S = StyleSheet.create({ + scanBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 14, borderRadius: 12 }, + scanBtnText: { fontFamily: fontFamily.sansMd, fontSize: 12, fontWeight: '600', letterSpacing: 2, textTransform: 'uppercase' }, + orRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + orLine: { flex: 1, height: 0.5 }, + orText: { fontFamily: fontFamily.sansMd, fontSize: 9, letterSpacing: 1.5, textTransform: 'uppercase' }, + sheetWrap: { position: 'absolute', bottom: 0, left: 0, right: 0 }, + sheet: { borderRadius: 20, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, padding: 14, paddingBottom: 32, borderWidth: 0.5 }, + grab: { width: 36, height: 4, borderRadius: 99, alignSelf: 'center', marginBottom: 14 }, + header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }, + tag: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' }, + title: { fontSize: 18, marginTop: 4, letterSpacing: -0.3 }, + closeBtn: { width: 30, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center' }, + fieldLabel: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' }, + inputRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: Platform.OS === 'ios' ? 13 : 10, borderRadius: 12 }, + input: { flex: 1, fontSize: 14, fontFamily: fontFamily.sansMd, padding: 0 }, + hint: { fontFamily: fontFamily.sansMd, fontSize: 10.5, letterSpacing: 0.2 }, + actionBtn: { padding: 13, borderRadius: 12, alignItems: 'center' }, + actionBtnText: { fontFamily: fontFamily.sansMd, fontSize: 11, fontWeight: '600', letterSpacing: 2.5, textTransform: 'uppercase' }, +}); diff --git a/mobile_app/app/scan.tsx b/mobile_app/app/scan.tsx new file mode 100644 index 0000000..12f8a4b --- /dev/null +++ b/mobile_app/app/scan.tsx @@ -0,0 +1,161 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, View, Text, Pressable, StyleSheet, Platform } from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { Feather } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { fontFamily, useTheme } from '@/theme'; +import { parseScannedAddress, resolveScan, type ScannedAddress } from '@/src/services/qrScan'; + +// Full-screen QR scanner route. Presented via `scan()` (src/services/qrScan). +// Being a navigator route — not a nested — is what makes this safe to +// open from anywhere, including from inside a bottom-sheet modal, without the +// stacked-window camera/touch bug that a nested causes on iOS. +export default function ScanScreen() { + const { colors } = useTheme(); + const [permission, requestPermission, getPermission] = useCameraPermissions(); + const scannedRef = useRef(false); + const settledRef = useRef(false); + const flashTimer = useRef | null>(null); + const [label, setLabel] = useState(null); + + // Resolve the pending scan() promise exactly once, then leave the route. + const finish = useCallback((result: ScannedAddress | null) => { + if (settledRef.current) return; + settledRef.current = true; + resolveScan(result); + if (router.canGoBack()) router.back(); + }, []); + + // Safety net: if the route unmounts without an explicit outcome (swipe- or + // hardware-back), still resolve null so the caller's promise never hangs. + useEffect(() => () => { + if (flashTimer.current) clearTimeout(flashTimer.current); + if (!settledRef.current) { settledRef.current = true; resolveScan(null); } + }, []); + + useEffect(() => { + if (permission && !permission.granted && permission.canAskAgain) requestPermission(); + }, [permission, requestPermission]); + + // Re-read permission when returning to foreground (deny → Settings → grant + // → back). getPermission reads OS state silently — no prompt. + useEffect(() => { + const sub = AppState.addEventListener('change', (next) => { + if (next === 'active') getPermission(); + }); + return () => sub.remove(); + }, [getPermission]); + + const onBarcodeScanned = useCallback(({ data }: { data: string }) => { + if (scannedRef.current) return; + scannedRef.current = true; + + const result = parseScannedAddress(data); + const hint = + result.type === 'lxmf' ? `LXMF · ${result.hash.slice(0, 8)}…` : + result.type === 'lxmf-group' ? `channel · ${result.name ?? result.addrHex.slice(0, 8)}…` : + result.type === 'solana' ? `Solana · ${result.address.slice(0, 8)}…` : + 'unknown format'; + setLabel(hint); + + // Brief flash of the recognized label, then hand the result back. + flashTimer.current = setTimeout(() => finish(result), 350); + }, [finish]); + + const denied = permission && !permission.granted && !permission.canAskAgain; + const granted = permission?.granted === true; + + return ( + + {denied ? ( + + + + Camera permission denied.{'\n'}Enable it in Settings. + + + ) : granted ? ( + + ) : ( + + + + Requesting camera access… + + + )} + + {/* Viewfinder */} + + + + + + + + {label && ( + + {label} + + )} + + + POINT AT A QR CODE + + + { if (!scannedRef.current) finish(null); }} hitSlop={12}> + + + + ); +} + +const CORNER = 28; +const BORDER = 3; + +const S = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#000' }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, paddingHorizontal: 32 }, + deniedText:{ fontFamily: fontFamily.sansMd, fontSize: 13, textAlign: 'center', lineHeight: 20 }, + + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', justifyContent: 'center', + }, + corner: { + position: 'absolute', width: CORNER, height: CORNER, + borderColor: '#fff', + top: '35%', left: '20%', + borderTopWidth: BORDER, borderLeftWidth: BORDER, borderRadius: 4, + }, + cornerTR: { left: undefined, right: '20%', borderLeftWidth: 0, borderRightWidth: BORDER }, + cornerBL: { top: undefined, bottom: '35%', borderTopWidth: 0, borderBottomWidth: BORDER }, + cornerBR: { top: undefined, bottom: '35%', left: undefined, right: '20%', borderTopWidth: 0, borderLeftWidth: 0, borderBottomWidth: BORDER, borderRightWidth: BORDER }, + + labelWrap: { + position: 'absolute', bottom: '38%', left: 0, right: 0, + alignItems: 'center', + }, + labelText: { + fontFamily: fontFamily.sansMd, fontSize: 12, letterSpacing: 1.5, + color: '#fff', backgroundColor: 'rgba(0,0,0,0.55)', + paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8, + }, + hint: { + position: 'absolute', bottom: 100, left: 0, right: 0, alignItems: 'center', + }, + hintText: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2.5, color: 'rgba(255,255,255,0.55)' }, + close: { + position: 'absolute', + top: Platform.OS === 'ios' ? 60 : 40, + right: 20, + width: 38, height: 38, borderRadius: 19, + backgroundColor: 'rgba(0,0,0,0.45)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/mobile_app/components/messages/JoinGroupModal.tsx b/mobile_app/components/messages/JoinGroupModal.tsx deleted file mode 100644 index 99f124b..0000000 --- a/mobile_app/components/messages/JoinGroupModal.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { - Modal, View, Text, TextInput, Pressable, - StyleSheet, Animated, ActivityIndicator, Platform, Keyboard, -} from 'react-native'; -import { Feather } from '@expo/vector-icons'; -import { fontFamily, useTheme } from '@/theme'; -import { useGlass } from '@/hooks/useGlass'; -import { QRScannerModal } from './QRScannerModal'; - -interface Props { - readonly visible: boolean; - readonly onClose: () => void; - readonly onJoin: (addrHex: string, keyHex: string, name?: string) => Promise; -} - -const ADDR_RE = /^[0-9a-fA-F]{32}$/; -const KEY_RE = /^[0-9a-fA-F]{32}$/; - -export function JoinGroupModal({ visible, onClose, onJoin }: Props) { - const { colors } = useTheme(); - const baseGlass = useGlass(); - const softGlass = useGlass('soft'); - - const [addrHex, setAddrHex] = useState(''); - const [keyHex, setKeyHex] = useState(''); - const [name, setName] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [scanner, setScanner] = useState(false); - - const sheetAnim = useRef(new Animated.Value(0)).current; - const kbOffset = useRef(new Animated.Value(0)).current; - - useEffect(() => { - if (visible) { - Animated.spring(sheetAnim, { toValue: 1, useNativeDriver: true, bounciness: 4 }).start(); - } - }, [visible, sheetAnim]); - - useEffect(() => { - const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; - const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; - const show = Keyboard.addListener(showEvent, e => { - Animated.timing(kbOffset, { - toValue: e.endCoordinates.height, - duration: Platform.OS === 'ios' ? e.duration : 150, - useNativeDriver: false, - }).start(); - }); - const hide = Keyboard.addListener(hideEvent, e => { - Animated.timing(kbOffset, { - toValue: 0, - duration: Platform.OS === 'ios' ? e.duration : 150, - useNativeDriver: false, - }).start(); - }); - return () => { show.remove(); hide.remove(); }; - }, [kbOffset]); - - const addrOk = ADDR_RE.test(addrHex.trim()); - const keyOk = KEY_RE.test(keyHex.trim()); - const canJoin = addrOk && keyOk && !loading; - - async function handleJoin() { - if (!canJoin) return; - setError(null); - setLoading(true); - try { - const ok = await onJoin(addrHex.trim(), keyHex.trim(), name.trim() || undefined); - if (ok) { dismiss(); } - else { setError('could not join — check address and key'); } - } catch { - setError('join failed — try again'); - } finally { - setLoading(false); - } - } - - function dismiss() { - Keyboard.dismiss(); - Animated.timing(sheetAnim, { toValue: 0, duration: 220, useNativeDriver: true }).start(() => { - setAddrHex(''); - setKeyHex(''); - setName(''); - setError(null); - onClose(); - }); - } - - const sheetY = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [500, 0], extrapolate: 'clamp' }); - const overlayOp = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }); - - return ( - <> - - - - - - - - - - - - - CHANNELS - join channel - - - - - - - - {/* Prominent QR scan button */} - setScanner(true)} style={[S.scanBtn, baseGlass]}> - - SCAN QR CODE - - - - - OR ENTER MANUALLY - - - - CHANNEL ADDRESS - - { setAddrHex(t); setError(null); }} - autoCapitalize="none" - autoCorrect={false} - /> - - - ENCRYPTION KEY - - { setKeyHex(t); setError(null); }} - autoCapitalize="none" - autoCorrect={false} - /> - - - - NICKNAME{' '}(optional) - - - - - - - {!!error && ( - {error} - )} - - - {loading - ? - : JOIN CHANNEL - } - - - - - - - - setScanner(false)} - onResult={r => { - setScanner(false); - if (r.type === 'lxmf-group') { - setAddrHex(r.addrHex); - setKeyHex(r.keyHex); - if (r.name && !name) setName(r.name); - setError(null); - } else if (r.type === 'lxmf') { - setAddrHex(r.hash); - setError(null); - } - }} - /> - - ); -} - -const S = StyleSheet.create({ - scanBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 14, borderRadius: 12 }, - scanBtnText: { fontFamily: fontFamily.sansMd, fontSize: 12, fontWeight: '600', letterSpacing: 2, textTransform: 'uppercase' }, - orRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, - orLine: { flex: 1, height: 0.5 }, - orText: { fontFamily: fontFamily.sansMd, fontSize: 9, letterSpacing: 1.5, textTransform: 'uppercase' }, - sheetWrap: { position: 'absolute', bottom: 0, left: 0, right: 0 }, - sheet: { borderRadius: 20, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, padding: 14, paddingBottom: 32, borderWidth: 0.5 }, - grab: { width: 36, height: 4, borderRadius: 99, alignSelf: 'center', marginBottom: 14 }, - header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }, - tag: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' }, - title: { fontSize: 18, marginTop: 4, letterSpacing: -0.3 }, - closeBtn: { width: 30, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center' }, - fieldLabel: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' }, - inputRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: Platform.OS === 'ios' ? 13 : 10, borderRadius: 12 }, - input: { flex: 1, fontSize: 14, fontFamily: fontFamily.sansMd, padding: 0 }, - hint: { fontFamily: fontFamily.sansMd, fontSize: 10.5, letterSpacing: 0.2 }, - actionBtn: { padding: 13, borderRadius: 12, alignItems: 'center' }, - actionBtnText: { fontFamily: fontFamily.sansMd, fontSize: 11, fontWeight: '600', letterSpacing: 2.5, textTransform: 'uppercase' }, -}); diff --git a/mobile_app/components/messages/PeersDrawer.tsx b/mobile_app/components/messages/PeersDrawer.tsx index 5731035..f3b9920 100644 --- a/mobile_app/components/messages/PeersDrawer.tsx +++ b/mobile_app/components/messages/PeersDrawer.tsx @@ -10,7 +10,7 @@ import { fontFamily, useTheme } from '@/theme'; import { Pill } from '@/components/ui/Pill'; import { Skeleton } from '@/components/ui/Skeleton'; import { useGlass } from '../../hooks/useGlass'; -import { QRScannerModal } from './QRScannerModal'; +import { scan } from '@/src/services/qrScan'; import { type Peer } from './constants'; interface Props { @@ -144,8 +144,34 @@ export const PeersDrawer = memo(function PeersDrawer({ const dmPeers = peers.filter(p => !p.isGroup); const online = dmPeers.filter(p => p.online).length; - const [input, setInput] = useState(''); - const [scannerOpen, setScannerOpen] = useState(false); + const [input, setInput] = useState(''); + + const handleScan = useCallback(async () => { + const result = await scan(); + if (!result) return; + if (__DEV__) console.log('[QR scan/PeersDrawer]', JSON.stringify(result)); + if (result.type === 'lxmf') { + onNewHash?.(result.hash); + } else if (result.type === 'lxmf-group') { + // Group QR scanned in peer-finder flow — route to join modal instead. + Alert.alert( + 'Looks like a channel QR', + 'Open "Join channel" and scan the same QR to join the channel.', + ); + } else if (result.type === 'solana') { + Alert.alert( + 'Solana address scanned', + 'This is a wallet address. Use the Send screen to send funds.', + ); + } else { + // unknown — surface the raw payload so the user can debug what they scanned + const preview = result.raw.length > 64 ? result.raw.slice(0, 64) + '…' : result.raw; + Alert.alert( + 'Unrecognized QR', + `Not a peer or channel QR. Scanned content:\n\n${preview}`, + ); + } + }, [onNewHash]); const isHash = /^[0-9a-fA-F]{16,}$/.test(input.trim()); const canStart = isHash; @@ -198,7 +224,7 @@ export const PeersDrawer = memo(function PeersDrawer({ )} - setScannerOpen(true)} hitSlop={8}> + @@ -318,35 +344,6 @@ export const PeersDrawer = memo(function PeersDrawer({ )} - setScannerOpen(false)} - onResult={result => { - setScannerOpen(false); - if (__DEV__) console.log('[QR scan/PeersDrawer]', JSON.stringify(result)); - if (result.type === 'lxmf') { - onNewHash?.(result.hash); - } else if (result.type === 'lxmf-group') { - // Group QR scanned in peer-finder flow — route to join modal instead. - Alert.alert( - 'Looks like a channel QR', - 'Open "Join channel" and scan the same QR to join the channel.', - ); - } else if (result.type === 'solana') { - Alert.alert( - 'Solana address scanned', - 'This is a wallet address. Use the Send screen to send funds.', - ); - } else { - // unknown — surface the raw payload so the user can debug what they scanned - const preview = result.raw.length > 64 ? result.raw.slice(0, 64) + '…' : result.raw; - Alert.alert( - 'Unrecognized QR', - `Not a peer or channel QR. Scanned content:\n\n${preview}`, - ); - } - }} - /> ); }); diff --git a/mobile_app/components/messages/QRScannerModal.tsx b/mobile_app/components/messages/QRScannerModal.tsx deleted file mode 100644 index be5ce2f..0000000 --- a/mobile_app/components/messages/QRScannerModal.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { AppState, Modal, View, Text, Pressable, StyleSheet, Platform } from 'react-native'; -import { CameraView, useCameraPermissions } from 'expo-camera'; -import { Feather } from '@expo/vector-icons'; -import { fontFamily, useTheme } from '@/theme'; -import { parseSolanaPayUri } from '@/src/services/solanaPayUri'; - -export type ScannedAddress = - | { type: 'lxmf'; hash: string } - | { type: 'lxmf-group'; addrHex: string; keyHex: string; name?: string } - | { - type: 'solana'; - address: string; - amount?: string; - splToken?: string; - label?: string; - message?: string; - memo?: string; - reference?: string[]; - } - | { type: 'unknown'; raw: string }; - -// 32-byte LXMF/Reticulum address = 64 hex chars (raw) or 32 (short hash shown in UI) -const LXMF_RE = /^[0-9a-f]{32}([0-9a-f]{32})?$/i; -const HEX32_RE = /^[0-9a-fA-F]{32}$/; - -function parse(raw: string): ScannedAddress { - const s = raw.trim(); - - // lxmf://group//?name= - if (s.startsWith('lxmf://group/')) { - const rest = s.slice('lxmf://group/'.length); - const [body, query] = rest.split('?') as [string, string | undefined]; - const parts = body.split('/'); - const addrHex = parts[0] ?? ''; - const keyHex = parts[1] ?? ''; - if (HEX32_RE.test(addrHex) && HEX32_RE.test(keyHex)) { - const nameParam = query?.split('&').find(p => p.startsWith('name='))?.slice(5); - const name = nameParam ? decodeURIComponent(nameParam) : undefined; - return { type: 'lxmf-group', addrHex: addrHex.toLowerCase(), keyHex: keyHex.toLowerCase(), name }; - } - } - - if (s.startsWith('lxmf://') || s.startsWith('reticulum://')) { - const hash = s.split('://')[1]?.split('?')[0] ?? ''; - if (LXMF_RE.test(hash)) return { type: 'lxmf', hash }; - } - - if (LXMF_RE.test(s)) return { type: 'lxmf', hash: s.toLowerCase() }; - - // Solana Pay URI or bare base58 — single source of truth in solanaPayUri.ts. - const pay = parseSolanaPayUri(s); - if (pay) { - return { - type: 'solana', - address: pay.recipient, - amount: pay.amount, - splToken: pay.splToken, - label: pay.label, - message: pay.message, - memo: pay.memo, - reference: pay.reference, - }; - } - - return { type: 'unknown', raw: s }; -} - -interface Props { - readonly visible: boolean; - readonly onResult: (result: ScannedAddress) => void; - readonly onClose: () => void; -} - -export function QRScannerModal({ visible, onResult, onClose }: Props) { - const { colors } = useTheme(); - const [permission, requestPermission, getPermission] = useCameraPermissions(); - const scannedRef = useRef(false); - const [label, setLabel] = useState(null); - - useEffect(() => { - if (visible) { scannedRef.current = false; setLabel(null); } - }, [visible]); - - useEffect(() => { - if (visible && permission && !permission.granted && permission.canAskAgain) { - requestPermission(); - } - }, [visible, permission, requestPermission]); - - // Refresh permission state when the app returns to foreground while the - // scanner is open — covers the "deny → open Settings → grant → return" - // flow. getPermission reads OS state silently (no prompt), so this is a - // no-op when nothing changed and grants live without a re-mount. - useEffect(() => { - if (!visible) return; - const sub = AppState.addEventListener('change', (next) => { - if (next === 'active') getPermission(); - }); - return () => sub.remove(); - }, [visible, getPermission]); - - const onBarcodeScanned = useCallback(({ data }: { data: string }) => { - if (scannedRef.current) return; - scannedRef.current = true; - - const result = parse(data); - const hint = - result.type === 'lxmf' ? `LXMF · ${result.hash.slice(0, 8)}…` : - result.type === 'lxmf-group' ? `channel · ${result.name ?? result.addrHex.slice(0, 8)}…` : - result.type === 'solana' ? `Solana · ${result.address.slice(0, 8)}…` : - 'unknown format'; - setLabel(hint); - - setTimeout(() => { onResult(result); }, 350); - }, [onResult]); - - if (!visible) return null; - - const denied = permission && !permission.granted && !permission.canAskAgain; - // Mount CameraView ONLY after permission is explicitly granted. On iOS the - // CameraView component caches its initial permission state at mount time — - // if we render it before the OS prompt resolves, the preview stays black - // even after the user taps "Allow," and an app restart is needed to recover. - // Gating on permission.granted means the flip from null→granted re-mounts - // CameraView fresh with the new permission in place. - const granted = permission?.granted === true; - - return ( - - - {denied ? ( - - - - Camera permission denied.{'\n'}Enable it in Settings. - - - ) : granted ? ( - - ) : ( - - - - Requesting camera access… - - - )} - - {/* Viewfinder */} - - - - - - - - {/* Label */} - {label && ( - - {label} - - )} - - {/* Instructions */} - - POINT AT A QR CODE - - - {/* Close */} - - - - - - ); -} - -const CORNER = 28; -const BORDER = 3; - -const S = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#000' }, - center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, paddingHorizontal: 32 }, - deniedText:{ fontFamily: fontFamily.sansMd, fontSize: 13, textAlign: 'center', lineHeight: 20 }, - - overlay: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', justifyContent: 'center', - }, - corner: { - position: 'absolute', width: CORNER, height: CORNER, - borderColor: '#fff', - top: '35%', left: '20%', - borderTopWidth: BORDER, borderLeftWidth: BORDER, borderRadius: 4, - }, - cornerTR: { left: undefined, right: '20%', borderLeftWidth: 0, borderRightWidth: BORDER }, - cornerBL: { top: undefined, bottom: '35%', borderTopWidth: 0, borderBottomWidth: BORDER }, - cornerBR: { top: undefined, bottom: '35%', left: undefined, right: '20%', borderTopWidth: 0, borderLeftWidth: 0, borderBottomWidth: BORDER, borderRightWidth: BORDER }, - - labelWrap: { - position: 'absolute', bottom: '38%', left: 0, right: 0, - alignItems: 'center', - }, - labelText: { - fontFamily: fontFamily.sansMd, fontSize: 12, letterSpacing: 1.5, - color: '#fff', backgroundColor: 'rgba(0,0,0,0.55)', - paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8, - }, - hint: { - position: 'absolute', bottom: 100, left: 0, right: 0, alignItems: 'center', - }, - hintText: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2.5, color: 'rgba(255,255,255,0.55)' }, - close: { - position: 'absolute', - top: Platform.OS === 'ios' ? 60 : 40, - right: 20, - width: 38, height: 38, borderRadius: 19, - backgroundColor: 'rgba(0,0,0,0.45)', - alignItems: 'center', justifyContent: 'center', - }, -}); diff --git a/mobile_app/components/send/RecipientPicker.tsx b/mobile_app/components/send/RecipientPicker.tsx index 1088139..a27d138 100644 --- a/mobile_app/components/send/RecipientPicker.tsx +++ b/mobile_app/components/send/RecipientPicker.tsx @@ -15,7 +15,7 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { QRScannerModal } from "@/components/messages/QRScannerModal"; +import { scan } from "@/src/services/qrScan"; import { DepthButton, TokenLogo } from "@/components/primitives"; import { TokenPicker, tokenByName } from "@/components/send/TokenPicker"; import type { TokenOption } from "@/components/send/TokenPicker"; @@ -93,7 +93,6 @@ export function RecipientPicker() { const [address, setAddress] = useState(typeof params.to === "string" ? params.to : ""); const [selectedSymbol, setSelectedSymbol] = useState("SOL"); const [pickerOpen, setPickerOpen] = useState(false); - const [scannerOpen, setScannerOpen] = useState(false); const [poisonAck, setPoisonAck] = useState(false); const { tokens } = useWalletBalance(); const { entries: addressBook, deleteRecipient } = useAddressBook(); @@ -138,9 +137,24 @@ export function RecipientPicker() { pushToAmount(trimmedAddress); } - function handleScan() { + async function handleScan() { haptics.tap(); - setScannerOpen(true); + const result = await scan(); + if (!result) return; + if (result.type !== "solana") { + Alert.alert( + "QR not recognised", + "Scan a Solana address or a Solana Pay code.", + ); + return; + } + haptics.confirm(); + handleAddressChange(result.address); + // SPL send is gated off (TokenPicker.isSendable allows SOL only), + // so ignore amount when an spl-token mint was specified. + if (result.amount && !result.splToken) { + pushToAmount(result.address, result.amount); + } } function handleSelectToken(next: TokenOption) { @@ -452,27 +466,6 @@ export function RecipientPicker() { onClose={() => setPickerOpen(false)} /> - setScannerOpen(false)} - onResult={(result) => { - setScannerOpen(false); - if (result.type !== "solana") { - Alert.alert( - "QR not recognised", - "Scan a Solana address or a Solana Pay code.", - ); - return; - } - haptics.confirm(); - handleAddressChange(result.address); - // SPL send is gated off (TokenPicker.isSendable allows SOL only), - // so ignore amount when an spl-token mint was specified. - if (result.amount && !result.splToken) { - pushToAmount(result.address, result.amount); - } - }} - /> ); } diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index bec0875..2096c17 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -1,7 +1,7 @@ import "@/polyfills"; import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import { useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'; import { View, ScrollView, Text, Image, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform, Keyboard, Dimensions, @@ -24,7 +24,6 @@ import { Composer } from '@/components/messages/Composer'; import { ThreadHeader } from '@/components/messages/ThreadHeader'; import { PeersDrawer } from '@/components/messages/PeersDrawer'; import { CreateGroupModal } from '@/components/messages/CreateGroupModal'; -import { JoinGroupModal } from '@/components/messages/JoinGroupModal'; import { ChannelShareSheet } from '@/components/messages/ChannelShareSheet'; import { GroupMembersSheet } from '@/components/messages/GroupMembersSheet'; import { type Peer } from '@/components/messages/constants'; @@ -259,7 +258,7 @@ export default function MessagesScreen() { const { isRunning, displayName, peers: lxmfPeers, events, send, getDisplayName, getPeerIdentity, getPeerMessages, myAddress, - groups, createGroup, joinGroup, leaveGroup, getGroupMembers, + groups, createGroup, leaveGroup, getGroupMembers, } = useLxmfContext(); const insets = useSafeAreaInsets(); @@ -270,7 +269,6 @@ export default function MessagesScreen() { const [activePeerHex, setActivePeerHex] = useState(null); const [actionGridVisible, setActionGridVisible] = useState(false); const [createGroupVisible, setCreateGroupVisible] = useState(false); - const [joinGroupVisible, setJoinGroupVisible] = useState(false); const [shareSheetOpen, setShareSheetOpen] = useState(false); const [membersSheetOpen, setMembersSheetOpen] = useState(false); const [seqStates, setSeqStates] = useState>(new Map()); @@ -650,7 +648,7 @@ export default function MessagesScreen() { colors={colors} bottomInset={insets.bottom} onCreateGroup={() => setCreateGroupVisible(true)} - onJoinGroup={() => setJoinGroupVisible(true)} + onJoinGroup={() => router.push('/join-channel')} /> ) : ( setCreateGroupVisible(true)} - onJoinGroup={() => setJoinGroupVisible(true)} + onJoinGroup={() => router.push('/join-channel')} onLeaveGroup={addrHex => leaveGroup(addrHex)} /> )} @@ -735,11 +733,6 @@ export default function MessagesScreen() { onClose={() => setCreateGroupVisible(false)} onCreate={createGroup} /> - setJoinGroupVisible(false)} - onJoin={joinGroup} - /> setShareSheetOpen(false)} diff --git a/mobile_app/src/services/qrScan.ts b/mobile_app/src/services/qrScan.ts new file mode 100644 index 0000000..7c8fce4 --- /dev/null +++ b/mobile_app/src/services/qrScan.ts @@ -0,0 +1,103 @@ +import { router } from 'expo-router'; +import { parseSolanaPayUri } from './solanaPayUri'; + +// Result of a QR scan. Discriminated on `type` so callers narrow safely. +export type ScannedAddress = + | { type: 'lxmf'; hash: string } + | { type: 'lxmf-group'; addrHex: string; keyHex: string; name?: string } + | { + type: 'solana'; + address: string; + amount?: string; + splToken?: string; + label?: string; + message?: string; + memo?: string; + reference?: string[]; + } + | { type: 'unknown'; raw: string }; + +// 32-byte LXMF/Reticulum address = 64 hex chars (raw) or 32 (short hash shown in UI) +const LXMF_RE = /^[0-9a-f]{32}([0-9a-f]{32})?$/i; +const HEX32_RE = /^[0-9a-fA-F]{32}$/; + +export function parseScannedAddress(raw: string): ScannedAddress { + const s = raw.trim(); + + // lxmf://group//?name= + if (s.startsWith('lxmf://group/')) { + const rest = s.slice('lxmf://group/'.length); + const [body, query] = rest.split('?') as [string, string | undefined]; + const parts = body.split('/'); + const addrHex = parts[0] ?? ''; + const keyHex = parts[1] ?? ''; + if (HEX32_RE.test(addrHex) && HEX32_RE.test(keyHex)) { + const nameParam = query?.split('&').find(p => p.startsWith('name='))?.slice(5); + const name = nameParam ? decodeURIComponent(nameParam) : undefined; + return { type: 'lxmf-group', addrHex: addrHex.toLowerCase(), keyHex: keyHex.toLowerCase(), name }; + } + } + + if (s.startsWith('lxmf://') || s.startsWith('reticulum://')) { + const hash = s.split('://')[1]?.split('?')[0] ?? ''; + if (LXMF_RE.test(hash)) return { type: 'lxmf', hash }; + } + + if (LXMF_RE.test(s)) return { type: 'lxmf', hash: s.toLowerCase() }; + + // Solana Pay URI or bare base58 — single source of truth in solanaPayUri.ts. + const pay = parseSolanaPayUri(s); + if (pay) { + return { + type: 'solana', + address: pay.recipient, + amount: pay.amount, + splToken: pay.splToken, + label: pay.label, + message: pay.message, + memo: pay.memo, + reference: pay.reference, + }; + } + + return { type: 'unknown', raw: s }; +} + +// ── Scanner presentation ────────────────────────────────────────────────── +// The scanner is a navigator route (`app/scan.tsx`), never a nested . +// That is the whole point: a core RN opens a separate native window, +// so rendering one inside another (e.g. a scanner inside a bottom-sheet modal) +// stacks two windows and breaks the camera preview + touch handling on iOS. +// Presenting via the navigator means there is exactly one scanner, mounted +// above everything, callable identically from any screen OR modal. + +let pendingResolve: ((result: ScannedAddress | null) => void) | null = null; +let pendingPromise: Promise | null = null; + +/** + * Open the full-screen QR scanner and resolve with the scanned result, or + * `null` if the user dismissed it (close button, back gesture, hardware back). + * Always resolves — the route's unmount guarantees it. + * + * Re-entrancy guard: there is exactly one scanner route at a time. A second + * call while one is already in flight (double-tap, overlapping flows) returns + * the SAME promise instead of pushing a duplicate `/scan` route — pushing a + * second route would orphan a ghost camera on the stack and let the + * module-global resolver cross-resolve the wrong caller with null. + */ +export function scan(): Promise { + if (pendingPromise) return pendingPromise; + pendingPromise = new Promise((resolve) => { + pendingResolve = resolve; + }); + router.push('/scan'); + return pendingPromise; +} + +/** Called by the scanner route to deliver its outcome exactly once. */ +export function resolveScan(result: ScannedAddress | null): void { + const resolve = pendingResolve; + pendingResolve = null; + pendingPromise = null; + resolve?.(result); +} From 5b9c2e30b672965a112280241287c82ff5d89cf6 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 01:52:36 -0800 Subject: [PATCH 05/94] test(tier0): align solanaPayUri label/message expectations with shipped lowercase default --- mobile_app/scripts/validate-tier0-services.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs index 19051be..4a660ab 100644 --- a/mobile_app/scripts/validate-tier0-services.mjs +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -72,7 +72,7 @@ function testSolanaPayUri() { recipient: "11111111111111111111111111111111", amount: "0,5", }), - "solana:11111111111111111111111111111111?amount=0.5&label=AnonMesh&message=AnonMesh+receive", + "solana:11111111111111111111111111111111?amount=0.5&label=anonmesh&message=anonmesh+receive", "comma decimal in amount must normalize to dot", ); assert.equal( @@ -80,7 +80,7 @@ function testSolanaPayUri() { recipient: "11111111111111111111111111111111", amount: "1,234500", }), - "solana:11111111111111111111111111111111?amount=1.234500&label=AnonMesh&message=AnonMesh+receive", + "solana:11111111111111111111111111111111?amount=1.234500&label=anonmesh&message=anonmesh+receive", "comma decimal with trailing zeros preserved in URI", ); // Whitespace plus comma is the case that locales actually emit. @@ -89,7 +89,7 @@ function testSolanaPayUri() { recipient: "11111111111111111111111111111111", amount: " 0,001 ", }), - "solana:11111111111111111111111111111111?amount=0.001&label=AnonMesh&message=AnonMesh+receive", + "solana:11111111111111111111111111111111?amount=0.001&label=anonmesh&message=anonmesh+receive", "padded comma decimal must trim and normalize", ); // Receive screen feeds a plain decimal string, not a locale-grouped number @@ -107,7 +107,7 @@ function testSolanaPayUri() { // amount= rather than crashing on undefined. assert.equal( buildSolanaPayUri({ recipient: "11111111111111111111111111111111" }), - "solana:11111111111111111111111111111111?label=AnonMesh&message=AnonMesh+receive", + "solana:11111111111111111111111111111111?label=anonmesh&message=anonmesh+receive", "missing amount must produce a recipient-only URI", ); } From 7928098f5f6bac172360236b533356c301cc7d07 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 02:35:18 -0800 Subject: [PATCH 06/94] fix(lxmf): surface silent identity-persist failures and fix peer-history undershoot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L3: a secureSet rejection when persisting the LXMF identity was swallowed in a console.warn. A failed write can spawn a fresh identity on next cold start — losing the mesh address and message continuity — with no user-visible signal. Route it to the existing LxmfErrorBanner via a new identityError state, cleared on a later successful persist. MSG-2: getPeerMessages fetched a fixed most-recent window (limit*2) then filtered to the peer, silently returning too few messages for a peer whose history sits deeper than that window. Grow the window until limit matches are found or a short page proves the store is drained. --- mobile_app/context/LxmfContext.tsx | 34 +++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index 0a84cab..7403a40 100644 --- a/mobile_app/context/LxmfContext.tsx +++ b/mobile_app/context/LxmfContext.tsx @@ -565,6 +565,10 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode }, [isNativeAvailable, isRunning, start, displayName, identityHydrated, storedIdentity, isBeacon, beaconKeypairReady, setBeaconKeypair, setBeaconSolanaRpc]); + // Surfaced when secure-storage rejects an identity write (off-grid audit §3), + // so a failed persist is visible instead of dying in a console.warn. + const [identityError, setIdentityError] = useState(null); + // Persist identity after node starts (using getIdentityHex() per new API) useEffect(() => { if (!isRunning) return; @@ -580,12 +584,16 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode created_at: new Date().toISOString(), }; secureSet(SecureKeys.LXMF_IDENTITY, JSON.stringify(blob)) - .then(() => setStoredIdentity(blob)) + .then(() => { + setStoredIdentity(blob); + setIdentityError(null); + }) .catch((err) => { - // Off-grid audit § 3: identity persist failure silently dropped means - // next cold start can spawn a new identity, losing message continuity. - // Surface it so we at least know when secure storage rejected. + // Off-grid audit § 3: a dropped identity persist means the next cold + // start can spawn a new identity, losing the mesh address + history. + // Surface it through the error banner instead of swallowing it. console.warn('[Lxmf] persist identity failed (next start may re-generate)', err); + setIdentityError('Identity not saved — secure storage rejected the write. Your mesh address may reset on next launch.'); }); }, [isRunning, lxmf.status?.addressHex, storedIdentity, getIdentityHex]); @@ -867,8 +875,18 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode const { fetchMessages: lxmfFetchMessages } = lxmf; const getPeerMessages = useCallback((destHash: string, limit = 200): StoredMessage[] => { - const all = lxmfFetchMessages(limit * 2) as StoredMessage[]; - return all.filter(m => m.source === destHash || m.dest === destHash).slice(0, limit); + // Native fetchMessages(n) returns only the most-recent n messages globally — + // there is no peer-scoped query. Filtering a fixed window (the old limit*2) + // silently undershoots for a peer whose messages sit deeper than that window. + // Grow the window until we have `limit` matches, or a short page proves the + // store is drained. Converges: window doubles until it exceeds total stored. + let window = Math.max(limit, 1) * 2; + for (;;) { + const all = lxmfFetchMessages(window) as StoredMessage[]; + const matches = all.filter(m => m.source === destHash || m.dest === destHash); + if (matches.length >= limit || all.length < window) return matches.slice(0, limit); + window *= 2; + } }, [lxmfFetchMessages]); const value = useMemo(() => ({ @@ -879,7 +897,7 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode status: lxmf.status, beacons: lxmf.beacons, events: lxmf.events, - error: lxmf.error, + error: lxmf.error ?? identityError, nameMap, displayName: displayName ?? '', myAddress: lxmf.status?.addressHex ?? storedIdentity?.address_hex ?? null, @@ -931,7 +949,7 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode isBeacon, setBeaconMode, beaconKeypairReady, beaconPubkeyHex, regenerateBeaconKeypair, getDisplayName, getPeerIdentity, getPeerMessages, lxmfFetchMessages, lxmf.partialSignExecutePayment, lxmf.extractNonceBlockhash, lxmf.isRunning, lxmf.isNativeAvailable, lxmf.status, lxmf.beacons, - lxmf.events, lxmf.error, lxmf.start, lxmf.stop, + lxmf.events, lxmf.error, identityError, lxmf.start, lxmf.stop, lxmf.broadcast, lxmf.getStatus, lxmf.getBeacons, lxmf.setLogLevel, lxmf.bleUnpairedRNodeCount, lxmf.getNusUnpairedRNodes, lxmf.pairNusRNode, lxmf.beaconRpc, lxmf.beaconBroadcastRpc]); From c0ed5d7bbf9d94d03cc39e9931d77b200859405c Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 02:40:38 -0800 Subject: [PATCH 07/94] feat(dev): guard app/dev/* routes behind __DEV__ Expo Router auto-registers every file in app/ as a deep-linkable route, so a production binary could open anonmesh://dev/* developer screens. Add a dev-group layout that redirects to the app shell unless __DEV__, keeping dev tools out of release builds. --- mobile_app/app/dev/_layout.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 mobile_app/app/dev/_layout.tsx diff --git a/mobile_app/app/dev/_layout.tsx b/mobile_app/app/dev/_layout.tsx new file mode 100644 index 0000000..2a4308a --- /dev/null +++ b/mobile_app/app/dev/_layout.tsx @@ -0,0 +1,15 @@ +import { Redirect, Stack } from 'expo-router'; + +/** + * Dev-only route group. Screens under `app/dev/*` are developer tools (loaders, + * harnesses) and must never be reachable in a production build — Expo Router + * otherwise auto-registers every file as a deep-linkable route, so a release + * binary would happily open `anonmesh://dev/...`. In production (`__DEV__` is + * false) we redirect any `/dev/*` entry back into the normal app shell. + */ +export default function DevLayout() { + if (!__DEV__) { + return ; + } + return ; +} From ad824808d84375ebbca5efcf989fe93a0eef4d47 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 02:40:38 -0800 Subject: [PATCH 08/94] fix(lxmf): gate dev local-PC hub behind __DEV__, not just an env var MY_PC (a developer's local Reticulum hub) was derived solely from EXPO_PUBLIC_LOCAL_LXMF_HOST and unshifted into the node's interface list in every build. EXPO_PUBLIC_* values inline into the production bundle when present in the build env, so a leaked var could wire a dev machine as a hub in a release binary. Gate on __DEV__ per project conventions (use __DEV__, not EXPO_PUBLIC_*, for dev-only code). --- mobile_app/context/LxmfContext.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index 0a84cab..2368b8a 100644 --- a/mobile_app/context/LxmfContext.tsx +++ b/mobile_app/context/LxmfContext.tsx @@ -309,7 +309,11 @@ export const G00N_HUB: TcpInterface = { host: 'dfw.us.g00n.cloud', port: 6969 export const BELETH_HUB: TcpInterface = { host: 'rns.beleth.net', port: 4242 }; const _myPcHost = process.env.EXPO_PUBLIC_LOCAL_LXMF_HOST; -export const MY_PC: TcpInterface | null = _myPcHost && _myPcHost !== 'localhost' +// Dev-only local-PC Reticulum hub. Gated on __DEV__ (not just the env var) so a +// production bundle never wires a developer's machine as a hub even if the +// EXPO_PUBLIC_LOCAL_LXMF_* vars are present in the build environment. Per +// CLAUDE.md: use __DEV__, not EXPO_PUBLIC_*, for dev-only conditionals. +export const MY_PC: TcpInterface | null = __DEV__ && _myPcHost && _myPcHost !== 'localhost' ? { host: _myPcHost, port: Number(process.env.EXPO_PUBLIC_LOCAL_LXMF_PORT ?? 4243) } : null; From 855752693ddb36ed3107875f9ffe2a6ea3677c5e Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 02:45:07 -0800 Subject: [PATCH 09/94] feat(dev): tutorial reset + dev menu for rehearsing first-run flows Adds resetTutorial() (clears the tutorial-completed flag) and an app/dev index screen that lists dev screens and exposes reset + replay actions, so onboarding and the tutorial can be rehearsed repeatedly without reinstalling. Lives under the __DEV__-guarded dev route group, so it never ships to production. --- mobile_app/app/dev/index.tsx | 70 ++++++++++++++++++++++++ mobile_app/src/services/tutorialState.ts | 11 +++- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 mobile_app/app/dev/index.tsx diff --git a/mobile_app/app/dev/index.tsx b/mobile_app/app/dev/index.tsx new file mode 100644 index 0000000..ac963e9 --- /dev/null +++ b/mobile_app/app/dev/index.tsx @@ -0,0 +1,70 @@ +import { Feather } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import React, { useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text } from 'react-native'; + +import { resetTutorial } from '@/src/services/tutorialState'; +import { useTheme } from '@/theme'; + +/** + * Dev menu — index of developer tools. Reachable only in development builds; the + * app/dev/_layout guard redirects to the app shell in production. Lets us open + * dev screens and reset first-run state to rehearse onboarding/tutorial flows + * repeatedly without reinstalling the app. + */ +export default function DevIndexScreen() { + const { colors } = useTheme(); + const [status, setStatus] = useState(null); + + const handleResetTutorial = async () => { + await resetTutorial(); + setStatus('Tutorial flag cleared — replay below or relaunch to see onboarding again.'); + }; + + return ( + + Dev menu + + Development build only. Tools for rehearsing flows without reinstalling. + + + SCREENS + router.push('/dev/pigeon-loader')} /> + + FIRST-RUN STATE + + router.push('/tutorial')} /> + + {status ? {status} : null} + + ); +} + +function Row({ colors, icon, label, onPress }: { + readonly colors: ReturnType['colors']; + readonly icon: keyof typeof Feather.glyphMap; + readonly label: string; + readonly onPress: () => void; +}) { + return ( + + + {label} + + + ); +} + +const S = StyleSheet.create({ + content: { padding: 20, paddingTop: 64, gap: 8 }, + title: { fontSize: 24, fontWeight: '700' }, + subtitle: { fontSize: 13, marginBottom: 16 }, + section: { fontSize: 11, fontWeight: '600', letterSpacing: 1, marginTop: 16, marginBottom: 4 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 14, borderRadius: 12, borderWidth: 0.5 }, + rowLabel: { flex: 1, fontSize: 15 }, + status: { fontSize: 13, marginTop: 16, lineHeight: 18 }, +}); diff --git a/mobile_app/src/services/tutorialState.ts b/mobile_app/src/services/tutorialState.ts index d6d638e..a69549a 100644 --- a/mobile_app/src/services/tutorialState.ts +++ b/mobile_app/src/services/tutorialState.ts @@ -1,4 +1,4 @@ -import { SecureKeys, secureGet, secureSet } from "@/src/storage"; +import { SecureKeys, secureDelete, secureGet, secureSet } from "@/src/storage"; const COMPLETE_VALUE = "true"; @@ -9,3 +9,12 @@ export async function hasCompletedTutorial(): Promise { export async function markTutorialCompleted(): Promise { await secureSet(SecureKeys.TUTORIAL_COMPLETED, COMPLETE_VALUE); } + +/** + * Clears the tutorial-completed flag so the onboarding tutorial replays on the + * next launch. Backs the dev menu's reset action — lets the first-run flow be + * rehearsed repeatedly without reinstalling the app. + */ +export async function resetTutorial(): Promise { + await secureDelete(SecureKeys.TUTORIAL_COMPLETED); +} From f605c256e721770694cf966277a58efd860b991f Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 02:45:07 -0800 Subject: [PATCH 10/94] fix(scripts): use a portable shebang for lxmf_send.py The shebang hardcoded a teammate's pipx venv path (/home/m4gicred1/...), so the LXMF send helper only ran on one machine. Use /usr/bin/env python3. --- mobile_app/scripts/lxmf_send.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile_app/scripts/lxmf_send.py b/mobile_app/scripts/lxmf_send.py index 66e29fd..7c6bc79 100755 --- a/mobile_app/scripts/lxmf_send.py +++ b/mobile_app/scripts/lxmf_send.py @@ -1,4 +1,4 @@ -#!/home/m4gicred1/.local/share/pipx/venvs/lxmf/bin/python3 +#!/usr/bin/env python3 """ Minimal LXMF send script with persistent identity. Usage: ./lxmf_send.py "message text" From 22d2f37eed10736343c41849eb33672311bd821b Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 02:49:40 -0800 Subject: [PATCH 11/94] fix(beacon): label beacon economic stats as a preview, not live earnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active BeaconRegistry card presented hardcoded figures — 0.000312 SOL 'earned', 24 co-signs, and derived rep/jitoSOL/yield — as if real, but the co-sign and staking economy is future work (see the in-file note, and the stake modal already reads 'not yet active'). Add a PREVIEW pill and a disclaimer so the illustrative figures no longer masquerade as real earnings; the reachable-peer count stays real. Exact copy/placement pending on-device visual confirmation. --- mobile_app/components/nodes/BeaconRegistry.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/mobile_app/components/nodes/BeaconRegistry.tsx b/mobile_app/components/nodes/BeaconRegistry.tsx index 28d52d3..0ecae4b 100644 --- a/mobile_app/components/nodes/BeaconRegistry.tsx +++ b/mobile_app/components/nodes/BeaconRegistry.tsx @@ -97,7 +97,10 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini BEACON REGISTRY - + + {active && } + + @@ -146,6 +149,10 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini {reachableCount}{' '}reachable + + + Preview — co-sign rewards, staking, and yield are not live yet; the figures above are illustrative. Reachable-peer count is real. + ) : ( <> @@ -305,6 +312,7 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini const S = StyleSheet.create({ wrap: { paddingHorizontal: 20, marginTop: 16, marginBottom: 16 }, labelRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, + pillRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, sectionLabel:{ fontFamily: fontFamily.sansMd, fontSize: 10, letterSpacing: 2, textTransform: 'uppercase' }, card: { borderRadius: 18, overflow: 'hidden', borderWidth: 0.5 }, @@ -327,6 +335,7 @@ const S = StyleSheet.create({ footerStat: { fontFamily: fontFamily.sansMd, fontSize: 12 }, footerNum: { fontFamily: fontFamily.sansBold, fontSize: 14 }, footerDot: { width: 3, height: 3, borderRadius: 2 }, + previewNote: { fontFamily: fontFamily.sansMd, fontSize: 10.5, lineHeight: 15, paddingHorizontal: 16, paddingBottom: 14, paddingTop: 12, borderTopWidth: 0.5 }, desc: { fontFamily: fontFamily.sansMd, fontSize: 12.5, lineHeight: 19, padding: 18, paddingBottom: 14 }, regBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 7, From b1959df21f82b96940a98317e98c9c1bf36b6e31 Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 05:48:29 -0800 Subject: [PATCH 12/94] test(lxmf): extract + unit-test the peer-history window algorithm (MSG-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the adaptive-window logic out of getPeerMessages into a pure collectPeerMessages() and adds tier0 assertions — including the deep-peer case the old fixed limit*2 window silently dropped (old: 0 found, new: all 5). getPeerMessages now delegates to the tested helper. --- mobile_app/context/LxmfContext.tsx | 24 ++++++------- .../scripts/validate-tier0-services.mjs | 35 +++++++++++++++++++ mobile_app/src/services/peerMessages.ts | 29 +++++++++++++++ 3 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 mobile_app/src/services/peerMessages.ts diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index 7403a40..9557ff3 100644 --- a/mobile_app/context/LxmfContext.tsx +++ b/mobile_app/context/LxmfContext.tsx @@ -19,6 +19,7 @@ import { import { generateNickname } from '@/components/onboarding/constants'; import { requestBLEPermissions } from '@/src/utils/blePermissions'; import { sliceNewEvents } from '@/src/utils/sliceNewEvents'; +import { collectPeerMessages } from '@/src/services/peerMessages'; import * as ExpoCrypto from 'expo-crypto'; import { ed25519 } from '@noble/curves/ed25519.js'; @@ -874,20 +875,15 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode }, [isRunning, stop]); const { fetchMessages: lxmfFetchMessages } = lxmf; - const getPeerMessages = useCallback((destHash: string, limit = 200): StoredMessage[] => { - // Native fetchMessages(n) returns only the most-recent n messages globally — - // there is no peer-scoped query. Filtering a fixed window (the old limit*2) - // silently undershoots for a peer whose messages sit deeper than that window. - // Grow the window until we have `limit` matches, or a short page proves the - // store is drained. Converges: window doubles until it exceeds total stored. - let window = Math.max(limit, 1) * 2; - for (;;) { - const all = lxmfFetchMessages(window) as StoredMessage[]; - const matches = all.filter(m => m.source === destHash || m.dest === destHash); - if (matches.length >= limit || all.length < window) return matches.slice(0, limit); - window *= 2; - } - }, [lxmfFetchMessages]); + const getPeerMessages = useCallback( + // Native fetchMessages(n) is most-recent-N-globally with no peer-scoped + // query; collectPeerMessages grows the window until it has `limit` matches + // or drains the store, so a peer's older messages aren't silently dropped. + // Logic is unit-tested in scripts/validate-tier0-services.mjs. + (destHash: string, limit = 200): StoredMessage[] => + collectPeerMessages((n) => lxmfFetchMessages(n) as StoredMessage[], destHash, limit), + [lxmfFetchMessages], + ); const value = useMemo(() => ({ isRunning: lxmf.isRunning, diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs index 4a660ab..a77e098 100644 --- a/mobile_app/scripts/validate-tier0-services.mjs +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -16,6 +16,7 @@ const { summarizeError } = await import("../src/utils/errors.ts"); const { buildDevnetExplorerTxUrl } = await import("../src/services/explorer.ts"); const { isWalletDenial } = await import("../src/utils/walletDenial.ts"); const { assertSendableSplProgram, UnsupportedTokenProgramError } = await import("../src/services/walletData.ts"); +const { collectPeerMessages } = await import("../src/services/peerMessages.ts"); function key(index) { const seed = new Uint8Array(32); @@ -406,6 +407,39 @@ function testSplProgramGuard() { ); } +function testCollectPeerMessages() { + // store[0] is the most-recent message; native fetchMessages(n) returns top n. + const makeFetch = (store) => (n) => store.slice(0, n); + + // 1) THE BUG THIS FIXES: a peer whose messages sit deep in the store (far past + // the old fixed limit*2 window) must still be found. The pre-fix code + // fetched only limit*2 and would return 0 here. + const deep = []; + for (let i = 0; i < 1000; i++) { + deep.push(i >= 900 && i <= 904 ? { source: "AAA", dest: "me" } : { source: "other", dest: "other2" }); + } + const deepRes = collectPeerMessages(makeFetch(deep), "AAA", 10); + assert.equal(deepRes.length, 5, "deep peer: all 5 messages found despite sitting past the initial window"); + assert.ok(deepRes.every((m) => m.source === "AAA"), "deep peer: only the peer's messages returned"); + + // 2) A peer dense in the recent window returns exactly `limit` (no over-fetch). + const dense = []; + for (let i = 0; i < 500; i++) dense.push({ source: i % 2 === 0 ? "BBB" : "x", dest: "me" }); + const denseRes = collectPeerMessages(makeFetch(dense), "BBB", 10); + assert.equal(denseRes.length, 10, "dense peer: returns exactly limit"); + + // 3) An absent peer must terminate (drained store) and return []. + assert.equal(collectPeerMessages(makeFetch(deep), "ZZZ", 10).length, 0, "absent peer: empty, loop terminates"); + + // 4) dest-side matches count (incoming messages), not just source. + const incoming = [{ source: "x", dest: "CCC" }, { source: "x", dest: "y" }]; + assert.equal(collectPeerMessages(makeFetch(incoming), "CCC", 10).length, 1, "incoming message to peer is matched"); + + // 5) Never returns more than `limit`. + const many = Array.from({ length: 100 }, () => ({ source: "DDD", dest: "me" })); + assert.equal(collectPeerMessages(makeFetch(many), "DDD", 7).length, 7, "result capped at limit"); +} + testSolanaPayUri(); testParseSolanaPayUri(); testAddressBookCore(); @@ -415,4 +449,5 @@ testExplorerUrls(); testErrorSummaries(); testWalletDenialPatterns(); testSplProgramGuard(); +testCollectPeerMessages(); console.log("Tier 0 service checks passed"); diff --git a/mobile_app/src/services/peerMessages.ts b/mobile_app/src/services/peerMessages.ts new file mode 100644 index 0000000..1240db0 --- /dev/null +++ b/mobile_app/src/services/peerMessages.ts @@ -0,0 +1,29 @@ +/** + * Collect up to `limit` messages involving `destHash` from a store that only + * exposes a "most-recent N globally" fetch (the native LXMF DB has no + * peer-scoped query). Filtering a single fixed window silently undershoots for + * a peer whose messages sit deeper than that window, so grow the window until + * we have `limit` matches — or a short page proves the store is drained. + * + * Converges: `window` doubles until it exceeds the total stored count, at which + * point a fetch returns fewer rows than requested (`all.length < window`) and + * the loop returns. Pure + injectable so it can be unit-tested without a device. + */ +export interface PeerScopedMessage { + source: string; + dest?: string; +} + +export function collectPeerMessages( + fetchRecent: (n: number) => T[], + destHash: string, + limit = 200, +): T[] { + let window = Math.max(limit, 1) * 2; + for (;;) { + const all = fetchRecent(window); + const matches = all.filter((m) => m.source === destHash || m.dest === destHash); + if (matches.length >= limit || all.length < window) return matches.slice(0, limit); + window *= 2; + } +} From 47bd71ebcb6837fc656511d5ee771cc8b09c7c5a Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 12:47:03 -0800 Subject: [PATCH 13/94] fix(lxmf): stop getGroupMembers triggering setState during render getGroupMembers runs inside a MessagesScreen useMemo (during render) and called lxmf.getStatus(), which refreshes LxmfProvider state -> 'Cannot update a component while rendering a different component' warning. Read the cached lxmf.status instead (identical for our own addressHex). Pre-existing bug, surfaced by on-device testing of the messages thread. --- mobile_app/context/LxmfContext.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index 0a84cab..a6ff256 100644 --- a/mobile_app/context/LxmfContext.tsx +++ b/mobile_app/context/LxmfContext.tsx @@ -794,7 +794,11 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode const getGroupMembers = useCallback((addrHex: string): string[] => { try { const msgs = lxmf.fetchMessages(500) as StoredMessage[]; - const ownHash = lxmf.getStatus()?.addressHex ?? storedIdentity?.address_hex; + // Read the cached status, not getStatus(): getGroupMembers runs inside a + // MessagesScreen useMemo (during render), and getStatus() triggers a + // LxmfProvider setState → "Cannot update a component while rendering + // another" warning. The cached value is identical for our own addressHex. + const ownHash = lxmf.status?.addressHex ?? storedIdentity?.address_hex; const seen = new Set(); for (const m of msgs) { const raw = m as unknown as Record; @@ -808,7 +812,7 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode return []; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lxmf.fetchMessages, lxmf.getStatus, storedIdentity]); + }, [lxmf.fetchMessages, lxmf.status, storedIdentity]); // Auto-route send: group addresses → sendGroup, peers → send const handleSend = useCallback(async ( From 557ad47c85f8ae2a6a4fcc20b054b96ec832136b Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 13:43:14 -0800 Subject: [PATCH 14/94] perf(lxmf): single bounded fetch in getPeerMessages (revert escalating loop) The adaptive-window loop re-fetched and re-scanned the whole message store on every conversation open (400->800->1600->... until drained), making threads take seconds to open on a busy mesh. Revert to a single most-recent-window fetch (limit*2). The deep-peer undershoot it tried to address needs a native per-peer query (catalogued), not a JS loop. Test updated to the bounded contract. --- .../scripts/validate-tier0-services.mjs | 36 ++++++++----------- mobile_app/src/services/peerMessages.ts | 22 +++++------- 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/mobile_app/scripts/validate-tier0-services.mjs b/mobile_app/scripts/validate-tier0-services.mjs index a77e098..745bdaf 100644 --- a/mobile_app/scripts/validate-tier0-services.mjs +++ b/mobile_app/scripts/validate-tier0-services.mjs @@ -411,33 +411,27 @@ function testCollectPeerMessages() { // store[0] is the most-recent message; native fetchMessages(n) returns top n. const makeFetch = (store) => (n) => store.slice(0, n); - // 1) THE BUG THIS FIXES: a peer whose messages sit deep in the store (far past - // the old fixed limit*2 window) must still be found. The pre-fix code - // fetched only limit*2 and would return 0 here. - const deep = []; - for (let i = 0; i < 1000; i++) { - deep.push(i >= 900 && i <= 904 ? { source: "AAA", dest: "me" } : { source: "other", dest: "other2" }); - } - const deepRes = collectPeerMessages(makeFetch(deep), "AAA", 10); - assert.equal(deepRes.length, 5, "deep peer: all 5 messages found despite sitting past the initial window"); - assert.ok(deepRes.every((m) => m.source === "AAA"), "deep peer: only the peer's messages returned"); - - // 2) A peer dense in the recent window returns exactly `limit` (no over-fetch). + // Filters the most-recent window (limit*2) to the peer, capped at limit. const dense = []; for (let i = 0; i < 500; i++) dense.push({ source: i % 2 === 0 ? "BBB" : "x", dest: "me" }); const denseRes = collectPeerMessages(makeFetch(dense), "BBB", 10); - assert.equal(denseRes.length, 10, "dense peer: returns exactly limit"); + assert.equal(denseRes.length, 10, "returns exactly limit (capped)"); + assert.ok(denseRes.every((m) => m.source === "BBB"), "only the peer's messages returned"); - // 3) An absent peer must terminate (drained store) and return []. - assert.equal(collectPeerMessages(makeFetch(deep), "ZZZ", 10).length, 0, "absent peer: empty, loop terminates"); - - // 4) dest-side matches count (incoming messages), not just source. + // dest-side matches count (incoming messages), not just source. const incoming = [{ source: "x", dest: "CCC" }, { source: "x", dest: "y" }]; - assert.equal(collectPeerMessages(makeFetch(incoming), "CCC", 10).length, 1, "incoming message to peer is matched"); + assert.equal(collectPeerMessages(makeFetch(incoming), "CCC", 10).length, 1, "incoming message matched via dest"); + + // Single BOUNDED fetch: only the most-recent window (limit*2) is scanned — no + // full-store re-scan. A peer whose messages sit entirely beyond that window is + // not returned. Deliberate perf tradeoff over the old escalating loop, which + // re-fetched the whole store and made opening a thread take seconds. + const deep = []; + for (let i = 0; i < 200; i++) deep.push(i >= 100 ? { source: "DEEP", dest: "me" } : { source: "x", dest: "y" }); + assert.equal(collectPeerMessages(makeFetch(deep), "DEEP", 10).length, 0, "peer beyond the window is not fetched (bounded by design)"); - // 5) Never returns more than `limit`. - const many = Array.from({ length: 100 }, () => ({ source: "DDD", dest: "me" })); - assert.equal(collectPeerMessages(makeFetch(many), "DDD", 7).length, 7, "result capped at limit"); + // Absent peer → []. + assert.equal(collectPeerMessages(makeFetch(dense), "ZZZ", 10).length, 0, "absent peer -> empty"); } testSolanaPayUri(); diff --git a/mobile_app/src/services/peerMessages.ts b/mobile_app/src/services/peerMessages.ts index 1240db0..2f9b21f 100644 --- a/mobile_app/src/services/peerMessages.ts +++ b/mobile_app/src/services/peerMessages.ts @@ -1,13 +1,14 @@ /** * Collect up to `limit` messages involving `destHash` from a store that only * exposes a "most-recent N globally" fetch (the native LXMF DB has no - * peer-scoped query). Filtering a single fixed window silently undershoots for - * a peer whose messages sit deeper than that window, so grow the window until - * we have `limit` matches — or a short page proves the store is drained. + * peer-scoped query). * - * Converges: `window` doubles until it exceeds the total stored count, at which - * point a fetch returns fewer rows than requested (`all.length < window`) and - * the loop returns. Pure + injectable so it can be unit-tested without a device. + * Single bounded fetch: pull one most-recent window and filter it. We + * deliberately do NOT grow-and-refetch until `limit` matches are found — that + * re-scanned the whole store on every conversation open and made opening a + * thread take seconds on a busy mesh. A peer whose history sits entirely beyond + * this window won't fully load here; closing that gap needs a native per-peer + * query (see CATALOGUE.md), not a JS loop. Pure + injectable for unit testing. */ export interface PeerScopedMessage { source: string; @@ -19,11 +20,6 @@ export function collectPeerMessages( destHash: string, limit = 200, ): T[] { - let window = Math.max(limit, 1) * 2; - for (;;) { - const all = fetchRecent(window); - const matches = all.filter((m) => m.source === destHash || m.dest === destHash); - if (matches.length >= limit || all.length < window) return matches.slice(0, limit); - window *= 2; - } + const all = fetchRecent(Math.max(limit, 1) * 2); + return all.filter((m) => m.source === destHash || m.dest === destHash).slice(0, limit); } From 689282341582449f5d694947c4fdf050a91a278e Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Tue, 26 May 2026 17:11:47 -0800 Subject: [PATCH 15/94] refactor(ui): drop dead Expo-template theme island, add shared state primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the unused starter-template stack (themed-text/view, parallax scroll view, hello-wave, external-link, collapsible, icon-symbol, constants/theme, use-theme-color, use-color-scheme) — it was a second, competing color/font source with zero real importers. Add EmptyState / ErrorState / LoadingState to components/ui as the canonical, token-driven blocks for the app's empty / error / loading moments. --- mobile_app/components/external-link.tsx | 25 ------ mobile_app/components/hello-wave.tsx | 19 ---- .../components/parallax-scroll-view.tsx | 79 ----------------- mobile_app/components/themed-text.tsx | 60 ------------- mobile_app/components/themed-view.tsx | 14 --- mobile_app/components/ui/EmptyState.tsx | 87 +++++++++++++++++++ mobile_app/components/ui/ErrorState.tsx | 43 +++++++++ mobile_app/components/ui/LoadingState.tsx | 42 +++++++++ mobile_app/components/ui/collapsible.tsx | 45 ---------- mobile_app/components/ui/icon-symbol.ios.tsx | 32 ------- mobile_app/components/ui/icon-symbol.tsx | 41 --------- mobile_app/components/ui/index.ts | 3 + mobile_app/constants/theme.ts | 53 ----------- mobile_app/hooks/use-color-scheme.ts | 1 - mobile_app/hooks/use-color-scheme.web.ts | 21 ----- mobile_app/hooks/use-theme-color.ts | 21 ----- 16 files changed, 175 insertions(+), 411 deletions(-) delete mode 100644 mobile_app/components/external-link.tsx delete mode 100644 mobile_app/components/hello-wave.tsx delete mode 100644 mobile_app/components/parallax-scroll-view.tsx delete mode 100644 mobile_app/components/themed-text.tsx delete mode 100644 mobile_app/components/themed-view.tsx create mode 100644 mobile_app/components/ui/EmptyState.tsx create mode 100644 mobile_app/components/ui/ErrorState.tsx create mode 100644 mobile_app/components/ui/LoadingState.tsx delete mode 100644 mobile_app/components/ui/collapsible.tsx delete mode 100644 mobile_app/components/ui/icon-symbol.ios.tsx delete mode 100644 mobile_app/components/ui/icon-symbol.tsx delete mode 100644 mobile_app/constants/theme.ts delete mode 100644 mobile_app/hooks/use-color-scheme.ts delete mode 100644 mobile_app/hooks/use-color-scheme.web.ts delete mode 100644 mobile_app/hooks/use-theme-color.ts diff --git a/mobile_app/components/external-link.tsx b/mobile_app/components/external-link.tsx deleted file mode 100644 index 883e515..0000000 --- a/mobile_app/components/external-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (process.env.EXPO_OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href, { - presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, - }); - } - }} - /> - ); -} diff --git a/mobile_app/components/hello-wave.tsx b/mobile_app/components/hello-wave.tsx deleted file mode 100644 index 5def547..0000000 --- a/mobile_app/components/hello-wave.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Animated from 'react-native-reanimated'; - -export function HelloWave() { - return ( - - 👋 - - ); -} diff --git a/mobile_app/components/parallax-scroll-view.tsx b/mobile_app/components/parallax-scroll-view.tsx deleted file mode 100644 index 6f674a7..0000000 --- a/mobile_app/components/parallax-scroll-view.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import { StyleSheet } from 'react-native'; -import Animated, { - interpolate, - useAnimatedRef, - useAnimatedStyle, - useScrollOffset, -} from 'react-native-reanimated'; - -import { ThemedView } from '@/components/themed-view'; -import { useColorScheme } from '@/hooks/use-color-scheme'; -import { useThemeColor } from '@/hooks/use-theme-color'; - -const HEADER_HEIGHT = 250; - -type Props = PropsWithChildren<{ - headerImage: ReactElement; - headerBackgroundColor: { dark: string; light: string }; -}>; - -export default function ParallaxScrollView({ - children, - headerImage, - headerBackgroundColor, -}: Props) { - const backgroundColor = useThemeColor({}, 'background'); - const colorScheme = useColorScheme() ?? 'light'; - const scrollRef = useAnimatedRef(); - const scrollOffset = useScrollOffset(scrollRef); - const headerAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: interpolate( - scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], - [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] - ), - }, - { - scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), - }, - ], - }; - }); - - return ( - - - {headerImage} - - {children} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - height: HEADER_HEIGHT, - overflow: 'hidden', - }, - content: { - flex: 1, - padding: 32, - gap: 16, - overflow: 'hidden', - }, -}); diff --git a/mobile_app/components/themed-text.tsx b/mobile_app/components/themed-text.tsx deleted file mode 100644 index d79d0a1..0000000 --- a/mobile_app/components/themed-text.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { StyleSheet, Text, type TextProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedTextProps = TextProps & { - lightColor?: string; - darkColor?: string; - type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; -}; - -export function ThemedText({ - style, - lightColor, - darkColor, - type = 'default', - ...rest -}: ThemedTextProps) { - const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); - - return ( - - ); -} - -const styles = StyleSheet.create({ - default: { - fontSize: 16, - lineHeight: 24, - }, - defaultSemiBold: { - fontSize: 16, - lineHeight: 24, - fontWeight: '600', - }, - title: { - fontSize: 32, - fontWeight: 'bold', - lineHeight: 32, - }, - subtitle: { - fontSize: 20, - fontWeight: 'bold', - }, - link: { - lineHeight: 30, - fontSize: 16, - color: '#0a7ea4', - }, -}); diff --git a/mobile_app/components/themed-view.tsx b/mobile_app/components/themed-view.tsx deleted file mode 100644 index 6f181d8..0000000 --- a/mobile_app/components/themed-view.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { View, type ViewProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedViewProps = ViewProps & { - lightColor?: string; - darkColor?: string; -}; - -export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { - const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); - - return ; -} diff --git a/mobile_app/components/ui/EmptyState.tsx b/mobile_app/components/ui/EmptyState.tsx new file mode 100644 index 0000000..42ea734 --- /dev/null +++ b/mobile_app/components/ui/EmptyState.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { View, Text, type ViewStyle } from 'react-native'; +import { Feather } from '@expo/vector-icons'; +import { useTheme } from '@/theme'; +import { Button } from './Button'; + +type Props = { + /** Feather icon name shown in the badge. */ + icon?: React.ComponentProps['name']; + title: string; + description?: string; + /** Optional primary action rendered below the copy. */ + action?: { label: string; onPress: () => void }; + iconColor?: string; + iconBg?: string; + /** Fill and center within the available space. Default true. */ + fill?: boolean; + style?: ViewStyle; +}; + +/** + * Canonical empty-state block: badged icon + title + supporting copy + optional + * action. Centered, token-driven. Use anywhere a list or section has no content + * yet so every "nothing here" moment reads the same across the app. + */ +export function EmptyState({ + icon = 'inbox', + title, + description, + action, + iconColor, + iconBg, + fill = true, + style, +}: Props) { + const { colors, spacing, radii, textVariants } = useTheme(); + + return ( + + + + + + + {title} + + + {description ? ( + + {description} + + ) : null} + + {action ? ( + +