diff --git a/mobile_app/app.json b/mobile_app/app.json
index 32110b2d..16a957b2 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/app/_layout.tsx b/mobile_app/app/_layout.tsx
index 838e2018..61f7db80 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 00000000..6c8f2bd6
--- /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 00000000..12f8a4b8
--- /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 99f124be..00000000
--- 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 cb8e4f65..f3b9920c 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,16 +344,6 @@ export const PeersDrawer = memo(function PeersDrawer({
)}
- setScannerOpen(false)}
- onResult={result => {
- setScannerOpen(false);
- if (result.type === 'lxmf') {
- onNewHash?.(result.hash);
- }
- }}
- />
);
});
diff --git a/mobile_app/components/messages/QRScannerModal.tsx b/mobile_app/components/messages/QRScannerModal.tsx
deleted file mode 100644
index 697639ef..00000000
--- a/mobile_app/components/messages/QRScannerModal.tsx
+++ /dev/null
@@ -1,213 +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;
-
- return (
-
-
- {denied ? (
-
-
-
- Camera permission denied.{'\n'}Enable it in Settings.
-
-
- ) : (
-
- )}
-
- {/* 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 1088139e..a27d1388 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/package-lock.json b/mobile_app/package-lock.json
index 4ee904e2..e5110c77 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 63f07bce..95852ebc 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",
diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx
index bec0875a..2096c17e 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