diff --git a/mobile_app/app/onboarding.tsx b/mobile_app/app/onboarding.tsx index 827b1e39..d12c5bdd 100644 --- a/mobile_app/app/onboarding.tsx +++ b/mobile_app/app/onboarding.tsx @@ -1,72 +1,45 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Alert, Animated, Image, StyleSheet, Text, View } from 'react-native'; -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StyleSheet, View } from 'react-native'; import { type Href, useRouter } from 'expo-router'; import { useIsFocused } from '@react-navigation/native'; import { useWallet } from '@/context/WalletContext'; import { useLxmfContext } from '@/context/LxmfContext'; -import { fontFamily, fontSize, spacing } from '@/theme'; import { - AsciiBackground, - CTAButtons, - LoadingOverlay, + BackupStep, + IntroHero, + RadioStep, + ScreenFade, } from '@/components/onboarding'; -import { ExportWalletModal } from '@/components/settings'; +import { PigeonLoader } from '@/components/ui'; import { BG } from '@/components/onboarding/constants'; -import { hasCompletedTutorial } from '@/src/services/tutorialState'; +import { hasCompletedTutorial, markTutorialCompleted } from '@/src/services/tutorialState'; const TUTORIAL_ROUTE = '/tutorial' as Href; +// First run: intro (looping pigeon montage + wallet buttons) → backup (local +// only) → radio. A returning user whose wallet hydrates back in skips straight +// through. +type Stage = 'intro' | 'backup' | 'radio'; + export default function OnboardingScreen() { const router = useRouter(); const isFocused = useIsFocused(); - const { createWallet, connectMWA, isSolanaMobile, isLoading, isConnected, isInitialized, publicKey, walletMode } = useWallet(); - const { displayName: nickname } = useLxmfContext(); - const insets = useSafeAreaInsets(); + const { createWallet, connectMWA, isLoading, isConnected, publicKey, walletMode } = useWallet(); + const { displayName: nickname, grantRadioConsent } = useLxmfContext(); + + const [stage, setStage] = useState('intro'); // Set only when THIS session created a fresh local wallet — distinguishes a - // new identity (offer recovery-key backup) from a back-nav into an already + // new identity (recovery-key backup step) from a back-nav into an already // connected wallet or an MWA connect (Seed Vault, nothing to export here). const justCreatedRef = useRef(false); - const [backupOpen, setBackupOpen] = useState(false); - - // QA-13: gate the CTA panel until wallet hydration settles, so returning users - // (whose wallet auto-restores) don't see the onboarding panel flash on every - // cold start. WalletProvider.initialize() always flips isLoading true→false on - // mount — even the no-wallet path — so once we've seen that round-trip (or a - // connected/initialized wallet) it's safe to paint the panel. The brief hidden - // window is exactly the previous flash window. - const sawLoadingRef = useRef(false); - const [hydrated, setHydrated] = useState(false); - useEffect(() => { - if (isLoading) { sawLoadingRef.current = true; return; } - if (sawLoadingRef.current || isConnected || isInitialized) setHydrated(true); - }, [isLoading, isConnected, isInitialized]); + // Set when the user taps either CTA this session. Lets us tell a fresh + // connect (walk the remaining steps) from a wallet that auto-restored + // (returning user — skip everything). + const actedRef = useRef(false); - // If already connected when screen mounts (back-nav from tabs), skip animation delay. -const overlayOpacity = useRef(new Animated.Value(0)).current; - const enteringOpacity = useRef(new Animated.Value(0)).current; - const statusOpacity = useRef(new Animated.Value(0)).current; - const nicknameOpacity = useRef(new Animated.Value(0)).current; - const btnOpacity = useRef(new Animated.Value(0)).current; - - useEffect(() => { - if (isLoading) { - Animated.sequence([ - Animated.timing(overlayOpacity, { toValue: 1, duration: 200, useNativeDriver: true }), - Animated.timing(enteringOpacity, { toValue: 1, duration: 300, useNativeDriver: true }), - Animated.timing(statusOpacity, { toValue: 1, duration: 300, useNativeDriver: true }), - Animated.timing(nicknameOpacity, { toValue: 1, duration: 300, useNativeDriver: true }), - Animated.timing(btnOpacity, { toValue: 1, duration: 300, useNativeDriver: true }), - ]).start(); - } else { - overlayOpacity .setValue(0); - enteringOpacity.setValue(0); - statusOpacity .setValue(0); - nicknameOpacity.setValue(0); - btnOpacity .setValue(0); - } - }, [isLoading, overlayOpacity, enteringOpacity, statusOpacity, nicknameOpacity, btnOpacity]); + // Which action put us in the loading state — drives the pigeon loader copy. + const [loadingReason, setLoadingReason] = useState<'create' | 'connect' | null>(null); const proceed = useCallback(() => { hasCompletedTutorial() @@ -74,36 +47,42 @@ const overlayOpacity = useRef(new Animated.Value(0)).current; .catch(() => router.replace(TUTORIAL_ROUTE)); }, [router]); + // Mark the intro seen and land in the app. /tutorial stays for manual replay. + const finish = useCallback(() => { + markTutorialCompleted() + .catch(() => undefined) + .finally(() => router.replace('/(tabs)')); + }, [router]); + useEffect(() => { - // Only auto-proceed while onboarding is the focused route. unstable_settings - // anchor mounts onboarding BENEATH a deep-linked route (anonmesh://tutorial), - // and router.replace targets the focused route — without this gate, wallet - // hydration replaced the just-opened tutorial with /(tabs), so the deep link - // appeared to do nothing on devices that already have a wallet. + // Only self-navigate while onboarding is the focused route. The + // unstable_settings anchor mounts onboarding BENEATH a deep-linked route + // (e.g. anonmesh://tutorial), and router.replace targets the focused + // route — without this gate, wallet hydration would replace the route the + // user just deep-linked into. if (!isFocused) return; if (!isConnected || !publicKey) return; - // Freshly created local wallet → offer the recovery-key backup before the - // user reaches the app. One honest line: the key is device-local, here's the - // export path. Non-blocking — "Later" continues straight through. - if (justCreatedRef.current && walletMode === 'local') { - justCreatedRef.current = false; - Alert.alert( - 'Back up your wallet', - 'Your wallet key is stored only on this device — anonmesh keeps no copy. Export your recovery key now and store it offline so you can restore your wallet if you lose this device.', - [ - { text: 'Later', style: 'cancel', onPress: proceed }, - { text: 'Back up now', onPress: () => setBackupOpen(true) }, - ], - ); + if (stage === 'backup' || stage === 'radio') return; + + if (actedRef.current) { + // Wallet just connected from a CTA tap this session. + if (justCreatedRef.current && walletMode === 'local') { + justCreatedRef.current = false; + setStage('backup'); + } else { + setStage('radio'); + } return; } + + // Auto-restored wallet (back-nav or returning user) — skip the intro. let cancelled = false; const t = setTimeout(() => { if (!cancelled) proceed(); }, 0); return () => { cancelled = true; clearTimeout(t); }; - }, [isFocused, isConnected, publicKey, walletMode, proceed]); + }, [isFocused, isConnected, publicKey, walletMode, stage, proceed]); const handleCreate = useCallback(async () => { if (isLoading) return; @@ -111,108 +90,68 @@ const overlayOpacity = useRef(new Animated.Value(0)).current; // a device passcode when no biometric is enrolled. Prompting here too caused // a double prompt, and on PIN-only devices the second (biometric-only) prompt // failed silently, leaving the button dead. + actedRef.current = true; justCreatedRef.current = true; + setLoadingReason('create'); await createWallet(); }, [isLoading, createWallet]); - const handleConnect = useCallback(async () => { if (!isLoading) await connectMWA(); }, [isLoading, connectMWA]); - + const handleConnect = useCallback(async () => { + if (isLoading) return; + actedRef.current = true; + setLoadingReason('connect'); + await connectMWA(); + }, [isLoading, connectMWA]); + + if (stage === 'backup') { + return ( + + setStage('radio')} /> + + ); + } + + if (stage === 'radio') { + return ( + + { + if (granted) grantRadioConsent(); + finish(); + }} + /> + + ); + } + + // intro — the real pigeon power-blur flyover looping as an animated image + // (no video decoder, can't freeze) with the wallet buttons. The pigeon loader + // flies over it while the wallet is created / connected. + const connecting = loadingReason === 'connect'; return ( - - - {/* ASCII animated hero */} - - - - - - {/* Bottom panel — extends behind home indicator, padding absorbs inset. - Held back until hydration settles (QA-13) so a returning user whose - wallet auto-restores never sees this panel flash before the redirect. */} - {hydrated && !isConnected && ( - - Join the Mesh - Encrypted communication and off-grid payments. - - - - - )} - - - - - {backupOpen && ( - { setBackupOpen(false); proceed(); }} - /> - )} + ); } const S = StyleSheet.create({ - root: { flex: 1, backgroundColor: BG }, - safe: { flex: 1 }, - hero: { flex: 1 }, - - panel: { - backgroundColor: '#08111a', - borderTopLeftRadius: 28, - borderTopRightRadius: 28, - paddingTop: 22, - paddingBottom: spacing[6], - gap: 10, - }, - heroLogo: { - position: 'absolute', - width: 500, height: 100, - alignSelf: 'center', - top: '50%', - marginTop: -50, - }, - title: { - fontSize: fontSize['3xl'], - fontWeight: '700', - color: '#d8eef4', - letterSpacing: -0.4, - textAlign: 'center', - paddingHorizontal: spacing[6], - fontFamily: fontFamily.sansMd, - }, - subtitle: { - fontFamily: fontFamily.sansSb, - fontSize: fontSize.sm, - color: '#3d6878', - textAlign: 'center', - letterSpacing: 0.3, - paddingHorizontal: spacing[6], - marginBottom: 2, - }, - // footer: { - // fontFamily: fontFamily.sansSb, - // fontSize: 10, - // color: '#ffffffbe', - // letterSpacing: 1.5, - // textAlign: 'center', - // marginTop: 4, - // }, + root: { flex: 1, backgroundColor: BG }, + fill: { flex: 1 }, }); diff --git a/mobile_app/app/tutorial.tsx b/mobile_app/app/tutorial.tsx index 2da6cb67..33fcb4d5 100644 --- a/mobile_app/app/tutorial.tsx +++ b/mobile_app/app/tutorial.tsx @@ -127,7 +127,7 @@ export default function TutorialScreen() { icon: "message-square", kicker: "Messages", title: "Encrypted chat, no internet.", - body: "Peer-to-peer messages over BLE, LoRa radio, or LAN. No servers, no phone number, no SIM. Open the Messages tab to start a conversation.", + body: "Peer-to-peer messages over BLE, LoRa radio, or LAN. No servers, no phone number, no SIM. Say hi to anyone nearby, or invite a friend to start a private chat.", statLabel: "Mesh ID", statValue: shortAddress(myAddress), }, diff --git a/mobile_app/assets/onboarding/scene-flock.webp b/mobile_app/assets/onboarding/scene-flock.webp new file mode 100644 index 00000000..ce69387b Binary files /dev/null and b/mobile_app/assets/onboarding/scene-flock.webp differ diff --git a/mobile_app/assets/onboarding/scene-flyover.webp b/mobile_app/assets/onboarding/scene-flyover.webp new file mode 100644 index 00000000..d456cf77 Binary files /dev/null and b/mobile_app/assets/onboarding/scene-flyover.webp differ diff --git a/mobile_app/assets/onboarding/scene-recede.webp b/mobile_app/assets/onboarding/scene-recede.webp new file mode 100644 index 00000000..5ecd4cd3 Binary files /dev/null and b/mobile_app/assets/onboarding/scene-recede.webp differ diff --git a/mobile_app/components/messages/PeersDrawer.tsx b/mobile_app/components/messages/PeersDrawer.tsx index daeb7be5..e8f1fbdb 100644 --- a/mobile_app/components/messages/PeersDrawer.tsx +++ b/mobile_app/components/messages/PeersDrawer.tsx @@ -1,5 +1,5 @@ import React, { memo, useState, useCallback, useMemo } from 'react'; -import { Alert, View, Text, TextInput, ScrollView, Pressable, StyleSheet } from 'react-native'; +import { Alert, Share, View, Text, TextInput, ScrollView, Pressable, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Feather } from '@expo/vector-icons'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; @@ -7,6 +7,7 @@ import Reanimated, { useSharedValue, useAnimatedStyle, withSpring, interpolate, Extrapolation, } from 'react-native-reanimated'; import { fontFamily, fontSize, radii, spacing, useTheme } from '@/theme'; +import { useLxmfContext } from '@/context/LxmfContext'; import { PeerIdenticon } from '@/components/primitives'; import { Pill } from '@/components/ui/Pill'; import { Skeleton } from '@/components/ui/Skeleton'; @@ -191,10 +192,59 @@ function TabBar({ active, onChange, colors }: { // ── Empty state ─────────────────────────────────────────────────────────────── -function EmptyState({ tab, hasInput, colors }: { +// The contacts tab's empty state is every new user's landing screen — it must +// hand them a live next step (cold-start brief: the lone user's first session +// has no possible "aha" unless this screen manufactures one), never a dead end. +function FirstRunCard({ peerCount, isOnline, onInvite, onBrowsePeers, colors }: { + readonly peerCount: number; + readonly isOnline: boolean; + readonly onInvite: () => void; + readonly onBrowsePeers: () => void; + readonly colors: ReturnType['colors']; +}) { + const reachable = isOnline && peerCount > 0; + return ( + + + {reachable + ? `${peerCount} ${peerCount === 1 ? 'person is' : 'people are'} on the mesh right now` + : "You're off-grid"} + + + {reachable + ? 'Say hi to anyone under ALL PEERS — or bring a friend onto the mesh.' + : 'The radio looks for phones nearby. anonmesh really shines once a friend has it too.'} + + + [S.frPrimary, { backgroundColor: colors.primary }, pressed && S.frPressed]} + accessibilityRole="button" + > + INVITE A FRIEND + + {reachable && ( + [S.frSecondary, { borderColor: colors.border }, pressed && S.frPressed]} + accessibilityRole="button" + > + BROWSE PEERS + + )} + + + ); +} + +function EmptyState({ tab, hasInput, colors, peerCount, isOnline, onInvite, onBrowsePeers }: { readonly tab: Tab; readonly hasInput: boolean; readonly colors: ReturnType['colors']; + readonly peerCount: number; + readonly isOnline: boolean; + readonly onInvite: () => void; + readonly onBrowsePeers: () => void; }) { if (hasInput) { return ( @@ -203,8 +253,19 @@ function EmptyState({ tab, hasInput, colors }: { ); } + if (tab === 'contacts') { + return ( + + ); + } const messages: Record = { - contacts: 'No conversations yet\nPick a peer from All Peers to start', + contacts: '', groups: 'No groups joined\nUse CREATE or JOIN above', all: 'No peers available\nMake sure the node is running', }; @@ -305,6 +366,7 @@ export const PeersDrawer = memo(function PeersDrawer({ const { colors } = useTheme(); const softGlass = useGlass('soft'); const { mode } = useNetworkMode(); + const { myAddress } = useLxmfContext(); const allPeers = peersProp ?? []; const groups = allPeers.filter(p => p.isGroup); @@ -313,6 +375,13 @@ export const PeersDrawer = memo(function PeersDrawer({ // this counts only radio-local peers — known-but-unreachable peers stay out. const online = dmPeers.filter(p => p.online).length; + const invite = useCallback(() => { + const id = myAddress ? `\nMy mesh id: ${myAddress}` : ''; + Share.share({ + message: `Join me on anonmesh — messages that work with no internet. https://anonme.sh${id}`, + }).catch(() => {}); + }, [myAddress]); + const [input, setInput] = useState(''); const handleScan = useCallback(async () => { @@ -470,7 +539,15 @@ export const PeersDrawer = memo(function PeersDrawer({ } {!syncing && filtered.length === 0 && ( - 0} colors={colors} /> + 0} + colors={colors} + peerCount={online} + isOnline={mode === 'online'} + onInvite={invite} + onBrowsePeers={() => onTabChange('all')} + /> )} @@ -507,6 +584,54 @@ const S = StyleSheet.create({ listContent: { paddingBottom: 28, paddingHorizontal: 6, paddingTop: 6 }, emptyNote: { fontFamily: fontFamily.sansMd, fontSize: fontSize.sm, textAlign: 'center', paddingTop: spacing[8], opacity: 0.5, lineHeight: 20 }, + // ── First-run card (contacts tab, zero conversations) ─────────────────────── + frCard: { + marginTop: spacing[7], + marginHorizontal: spacing[2], + borderRadius: radii.lg, + borderWidth: 0.5, + padding: spacing[6], + gap: spacing[3], + }, + frTitle: { + fontFamily: fontFamily.sansBold, + fontSize: fontSize.lg, + lineHeight: 24, + }, + frBody: { + fontFamily: fontFamily.sans, + fontSize: fontSize.sm, + lineHeight: 20, + }, + frButtons: { gap: spacing[3], marginTop: spacing[2] }, + frPrimary: { + height: 46, + borderRadius: radii.full, + alignItems: 'center', + justifyContent: 'center', + }, + frPrimaryText: { + fontFamily: fontFamily.sansMd, + fontSize: fontSize.sm, + fontWeight: '800', + color: '#001820', + letterSpacing: 1.5, + }, + frSecondary: { + height: 46, + borderRadius: radii.full, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + }, + frSecondaryText: { + fontFamily: fontFamily.sansMd, + fontSize: fontSize.sm, + fontWeight: '700', + letterSpacing: 1.5, + }, + frPressed: { opacity: 0.85 }, + // ── Row ────────────────────────────────────────────────────────────────────── row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 11, borderRadius: radii.lg, borderWidth: 0.5 }, swipeWrap: { borderRadius: radii.lg, overflow: 'hidden', marginHorizontal: 0 }, diff --git a/mobile_app/components/onboarding/AsciiBackground.tsx b/mobile_app/components/onboarding/AsciiBackground.tsx deleted file mode 100644 index c460f57a..00000000 --- a/mobile_app/components/onboarding/AsciiBackground.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import React, { memo, useCallback } from 'react'; -import { StyleSheet, View } from 'react-native'; -import { GLView, type ExpoWebGLRenderingContext } from 'expo-gl'; -import * as THREE from 'three'; - -// 10 chars: ' .:-=+*#%@' — each is 8 rows of 8-bit bitmaps (MSB = left col) -const FONT: number[][] = [ - [0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00], // ' ' - [0x00,0x00,0x00,0x00,0x00,0x00,0x18,0x18], // '.' - [0x00,0x18,0x18,0x00,0x00,0x18,0x18,0x00], // ':' - [0x00,0x00,0x00,0x7E,0x7E,0x00,0x00,0x00], // '-' - [0x00,0x00,0x7E,0x00,0x00,0x7E,0x00,0x00], // '=' - [0x00,0x18,0x18,0x7E,0x7E,0x18,0x18,0x00], // '+' - [0x00,0x42,0x24,0x18,0x18,0x24,0x42,0x00], // '*' - [0x24,0x24,0xFF,0x24,0x24,0xFF,0x24,0x00], // '#' - [0x00,0x62,0x64,0x08,0x10,0x26,0x46,0x00], // '%' - [0x3C,0x66,0x6E,0x6A,0x6C,0x60,0x3E,0x00], // '@' -]; - -function buildFontTexture(): THREE.DataTexture { - const N = FONT.length, SZ = 8; - const data = new Uint8Array(N * SZ * SZ * 4); - for (let c = 0; c < N; c++) { - for (let row = 0; row < SZ; row++) { - const pattern = FONT[c][row]; - for (let col = 0; col < SZ; col++) { - const bit = (pattern >> (7 - col)) & 1; - const i = (row * N * SZ + c * SZ + col) * 4; - data[i] = data[i+1] = data[i+2] = bit * 255; - data[i+3] = 255; - } - } - } - const t = new THREE.DataTexture(data, N * SZ, SZ, THREE.RGBAFormat); - t.magFilter = THREE.NearestFilter; - t.minFilter = THREE.NearestFilter; - t.needsUpdate = true; - return t; -} - -export const AsciiBackground = memo(function AsciiBackground() { - const onContextCreate = useCallback((gl: ExpoWebGLRenderingContext) => { - const W = gl.drawingBufferWidth; - const H = gl.drawingBufferHeight; - - const renderer = new THREE.WebGLRenderer({ - canvas: { - width: W, height: H, - style: {} as CSSStyleDeclaration, - addEventListener: (() => {}) as typeof HTMLCanvasElement.prototype.addEventListener, - removeEventListener: (() => {}) as typeof HTMLCanvasElement.prototype.removeEventListener, - clientHeight: H, - getContext: () => gl as unknown as RenderingContext, - } as unknown as HTMLCanvasElement, - context: gl as unknown as WebGLRenderingContext, - antialias: false, - powerPreference: 'high-performance', - }); - renderer.setSize(W, H); - renderer.setClearColor(0x00080c, 1); - - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(70, W / H, 0.1, 1000); - camera.position.set(0, 0, 18); - camera.lookAt(0, 0, 0); - - const ribbonCount = 4; - const pointsPerRibbon = 2000; - const count = ribbonCount * pointsPerRibbon; - const base = new Float32Array(count * 3); - const colors = new Float32Array(count * 3); - - for (let r = 0; r < ribbonCount; r++) { - const zOff = (r - (ribbonCount - 1) / 2) * 3.5; - for (let i = 0; i < pointsPerRibbon; i++) { - const idx = r * pointsPerRibbon + i; - base[idx*3] = (i / pointsPerRibbon) * 44 - 22; - base[idx*3+1] = (Math.random() - 0.5) * 2; - base[idx*3+2] = zOff + (Math.random() - 0.5) * 1.5; - const b = 0.5 + 0.5 * (r / (ribbonCount - 1)); - const br = 0.75 + Math.random() * 0.25; - colors[idx*3] = 0; - colors[idx*3+1] = b * br * 0.898; // #00e5ff G channel ratio - colors[idx*3+2] = b * br; - } - } - - const geo = new THREE.BufferGeometry(); - geo.setAttribute('position', new THREE.BufferAttribute(base.slice(), 3)); - geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); - geo.setAttribute('aBase', new THREE.BufferAttribute(base, 3)); - - const ribbonMat = new THREE.ShaderMaterial({ - uniforms: { uTime: { value: 0 } }, - vertexShader: ` - uniform float uTime; - attribute vec3 aBase; - varying vec3 vColor; - void main() { - vColor = color; - vec3 p = aBase; - p.y += sin(p.x * 0.18 + uTime * 0.9) * 2.8 - + sin(p.x * 0.42 - uTime * 1.7) * 0.6; - p.x += sin(uTime * 0.4 + p.z) * 0.6; - vec4 mv = modelViewMatrix * vec4(p, 1.0); - gl_PointSize = 3.0 * (220.0 / -mv.z); - gl_Position = projectionMatrix * mv; - } - `, - fragmentShader: ` - varying vec3 vColor; - void main() { - float d = length(gl_PointCoord - vec2(0.5)); - if (d > 0.5) discard; - gl_FragColor = vec4(vColor, 0.9 * (1.0 - d * 1.4)); - } - `, - transparent: true, - vertexColors: true, - blending: THREE.AdditiveBlending, - depthWrite: false, - }); - - scene.add(new THREE.Points(geo, ribbonMat)); - - const rt = new THREE.WebGLRenderTarget(W, H, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); - - const fontTex = buildFontTexture(); - const asciiScene = new THREE.Scene(); - const asciiCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const asciiMat = new THREE.ShaderMaterial({ - uniforms: { - tScene: { value: rt.texture }, - tFont: { value: fontTex }, - uResolution: { value: new THREE.Vector2(W, H) }, - }, - vertexShader: ` - varying vec2 vUv; - void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); } - `, - fragmentShader: ` - uniform sampler2D tScene; - uniform sampler2D tFont; - uniform vec2 uResolution; - void main() { - const float CELL = 8.0; - const float CHARS = 10.0; - vec2 fragCoord = gl_FragCoord.xy; - vec2 cellIdx = floor(fragCoord / CELL); - vec2 pixInCell = mod(fragCoord, CELL); - vec2 sUV = (cellIdx * CELL + CELL * 0.5) / uResolution; - sUV.y = 1.0 - sUV.y; - vec4 s = texture2D(tScene, sUV); - float lum = dot(s.rgb, vec3(0.299, 0.587, 0.114)); - float charIdx = floor(clamp(lum, 0.0, 0.9999) * CHARS); - vec2 fUV = vec2( - (charIdx * CELL + pixInCell.x + 0.5) / (CHARS * CELL), - (pixInCell.y + 0.5) / CELL - ); - float bit = texture2D(tFont, fUV).r; - if (bit < 0.5) discard; - vec3 col = mix(vec3(0.0, 0.898, 1.0), s.rgb * 1.4, 0.25); - gl_FragColor = vec4(col, 0.60 * lum * 1.8); - } - `, - transparent: true, - depthWrite: false, - }); - - asciiScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), asciiMat)); - - let time = 0; - let raf: ReturnType; - let last = performance.now(); - - const animate = () => { - raf = requestAnimationFrame(animate); - const now = performance.now(); - if (now - last < 1000 / 30) return; - last = now; - time += 0.016; - ribbonMat.uniforms.uTime.value = time; - renderer.setRenderTarget(rt); - renderer.render(scene, camera); - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(asciiScene, asciiCamera); - gl.endFrameEXP(); - }; - animate(); - - return () => { - cancelAnimationFrame(raf); - geo.dispose(); - ribbonMat.dispose(); - rt.dispose(); - fontTex.dispose(); - asciiMat.dispose(); - renderer.dispose(); - }; - }, []); - - return ( - - - - ); -}); diff --git a/mobile_app/components/onboarding/BackupStep.tsx b/mobile_app/components/onboarding/BackupStep.tsx new file mode 100644 index 00000000..6f04ca24 --- /dev/null +++ b/mobile_app/components/onboarding/BackupStep.tsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { DepthButton, Icon } from '@/components/primitives'; +import { ExportWalletModal } from '@/components/settings'; +import { fontSize, radii, spacing, useTheme } from '@/theme'; + +type Props = Readonly<{ onDone: () => void }>; + +/** + * Recovery-key backup as a real onboarding step (replaces the old Alert). + * Non-custodial stakes stated plainly; "back up now" opens the existing + * ExportWalletModal (biometric-gated, screen-capture protected). + */ +export function BackupStep({ onDone }: Props) { + const { colors, fontFamily } = useTheme(); + const insets = useSafeAreaInsets(); + const [exportOpen, setExportOpen] = useState(false); + const [backedUp, setBackedUp] = useState(false); + + return ( + + + + + + + + + + One key. Yours. + + + Back up your recovery key + + + Your wallet key lives only on this phone — anonmesh keeps no copy and + nobody can reset it. Lose the phone without a backup and your funds + are gone for good. + + + {backedUp && ( + + + + Key exported — store it offline + + + )} + + + + setExportOpen(true)} + size="lg" + tone="cyan" + variant="primary" + style={S.footerButton} + /> + {!backedUp && ( + + + Skip for now — I understand the risk + + + )} + + + {exportOpen && ( + { + setExportOpen(false); + setBackedUp(true); + }} + /> + )} + + ); +} + +const S = StyleSheet.create({ + root: { flex: 1 }, + content: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing[7], + }, + iconGlow: { + alignItems: 'center', + justifyContent: 'center', + width: 148, + height: 148, + borderRadius: radii.full, + marginBottom: 28, + }, + iconShell: { + alignItems: 'center', + justifyContent: 'center', + width: 96, + height: 96, + borderRadius: radii.md, + borderWidth: 1, + }, + kicker: { + fontSize: fontSize.sm, + letterSpacing: 1.5, + marginBottom: spacing[3], + textTransform: 'uppercase', + }, + title: { + fontSize: fontSize['3xl'], + lineHeight: 36, + marginBottom: spacing[5], + textAlign: 'center', + }, + body: { + fontSize: fontSize.lg, + lineHeight: 26, + maxWidth: 360, + textAlign: 'center', + }, + doneTile: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing[3], + marginTop: spacing[6], + paddingHorizontal: spacing[5], + paddingVertical: 12, + borderRadius: radii.lg, + borderWidth: 0.5, + }, + doneText: { fontSize: fontSize.sm }, + footer: { + paddingHorizontal: spacing[6], + paddingTop: spacing[3], + gap: spacing[4], + }, + footerButton: { width: '100%' }, + later: { + alignItems: 'center', + minHeight: 36, + justifyContent: 'center', + }, + laterText: { fontSize: fontSize.sm }, +}); diff --git a/mobile_app/components/onboarding/GlowOrbs.tsx b/mobile_app/components/onboarding/GlowOrbs.tsx deleted file mode 100644 index ddde2b7c..00000000 --- a/mobile_app/components/onboarding/GlowOrbs.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { memo } from 'react'; -import { View, StyleSheet } from 'react-native'; -import { radii } from '@/theme'; -import { CYAN } from './constants'; - -export const GlowOrbs = memo(function GlowOrbs() { - return ( - <> - - - - ); -}); - -const S = StyleSheet.create({ - top: { - position: 'absolute', top: -80, alignSelf: 'center', - width: 500, height: 500, borderRadius: radii.full, - backgroundColor: CYAN, opacity: 0.055, - }, - bottom: { - position: 'absolute', bottom: -120, right: '15%', - width: 280, height: 280, borderRadius: radii.full, - backgroundColor: CYAN, opacity: 0.025, - }, -}); diff --git a/mobile_app/components/onboarding/IntroHero.tsx b/mobile_app/components/onboarding/IntroHero.tsx new file mode 100644 index 00000000..4b650f90 --- /dev/null +++ b/mobile_app/components/onboarding/IntroHero.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Image } from 'expo-image'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { fontFamily, fontSize, spacing } from '@/theme'; +import { BG } from './constants'; +import { CTAButtons } from './CTAButtons'; + +// The onboarding story, as three looping scenes. Each is an animated WebP played +// by expo-image (the image pipeline — no video decoder, so nothing can freeze). +// Order: pigeon flyover → flock → receding birds. +const SCENES = [ + require('@/assets/onboarding/scene-flyover.webp'), + require('@/assets/onboarding/scene-flock.webp'), + require('@/assets/onboarding/scene-recede.webp'), +]; + +// Scene pacing. Each scene owns the screen for SLOT ms, then the next scene +// fades in ON TOP over FADE ms (a cover dissolve: only the incoming layer +// animates, so there is no mid-fade brightness dip and nothing else moving). +// A scene is perceivable for at most SLOT + FADE ≈ 5.9s, which sits inside +// every WebP's loop length (flyover 6.02s / flock 6.10s / recede 6.08s) — so +// a scene is always covered BEFORE its loop wraps. The wrap is a hard cut +// back to frame 0; letting it show on screen reads as a janky reset. +const SLOT = 5000; +const FADE = 900; + +type Props = Readonly<{ + isLoading: boolean; + onCreate: () => void; + onConnect: () => void; +}>; + +type Rotation = Readonly<{ + active: number; + // Per-scene remount counters: bumping one restarts that WebP from frame 0 + // the moment it starts fading in, so playback is deterministic every cycle. + generation: readonly number[]; + // Per-scene stacking order: the incoming scene always gets the highest + // zIndex (3) so it covers the outgoing (2) as it fades in. + zOrder: readonly number[]; +}>; + +// One screen: the looping three-scene story full-bleed, the hook, and the wallet +// buttons. No separate "Get started" step — one tap to create. +export function IntroHero({ isLoading, onCreate, onConnect }: Props) { + const insets = useSafeAreaInsets(); + + const [rotation, setRotation] = useState({ + active: 0, + generation: [0, 0, 0], + zOrder: [3, 1, 1], + }); + + const o0 = useSharedValue(1); + const o1 = useSharedValue(0); + const o2 = useSharedValue(0); + const opacities = useRef([o0, o1, o2]).current; + + const advance = useCallback(() => { + setRotation((r) => { + const next = (r.active + 1) % SCENES.length; + const generation = [...r.generation]; + generation[next] += 1; + const zOrder = SCENES.map((_, i) => (i === next ? 3 : i === r.active ? 2 : 1)); + return { active: next, generation, zOrder }; + }); + }, []); + + const { active } = rotation; + useEffect(() => { + // Cover dissolve: the incoming scene rises 0→1 above the others. Once it + // is fully opaque the layers underneath are invisible, so they are snapped + // to 0 (no animation — nothing perceivable changes) ready for their next + // turn. The outgoing scene keeps playing untouched through the dissolve. + const incoming = opacities[active]; + incoming.value = 0; + incoming.value = withTiming(1, { + duration: FADE, + easing: Easing.inOut(Easing.ease), + }); + const cover = setTimeout(() => { + opacities.forEach((o, i) => { + if (i !== active) o.value = 0; + }); + }, FADE + 50); + const timer = setTimeout(advance, SLOT); + return () => { + clearTimeout(cover); + clearTimeout(timer); + }; + }, [active, advance, opacities]); + + const s0 = useAnimatedStyle(() => ({ opacity: o0.value })); + const s1 = useAnimatedStyle(() => ({ opacity: o1.value })); + const s2 = useAnimatedStyle(() => ({ opacity: o2.value })); + const sceneStyles = [s0, s1, s2]; + + return ( + + {SCENES.map((src, i) => ( + + + + ))} + + + + + Your messages fly different. + + Phone to phone. No internet, no accounts, no phone number — your identity is a key. + + + + + + + ); +} + +const S = StyleSheet.create({ + root: { flex: 1, backgroundColor: BG }, + + // The scrim and panel sit above the scene layers (zIndex 1-3). + scrim: { zIndex: 10 }, + panel: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingHorizontal: spacing[7], + zIndex: 11, + }, + title: { + fontFamily: fontFamily.sansBold, + fontSize: fontSize['4xl'], + lineHeight: 40, + color: '#f2fdff', + letterSpacing: -0.6, + textAlign: 'center', + }, + subtitle: { + fontFamily: fontFamily.sans, + fontSize: fontSize.md, + lineHeight: 22, + color: 'rgba(216,238,244,0.82)', + textAlign: 'center', + marginTop: spacing[3], + }, + buttons: { marginTop: spacing[6], marginHorizontal: -spacing[7] }, +}); diff --git a/mobile_app/components/onboarding/LoadingOverlay.tsx b/mobile_app/components/onboarding/LoadingOverlay.tsx deleted file mode 100644 index 4e9a4176..00000000 --- a/mobile_app/components/onboarding/LoadingOverlay.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React, { memo, useEffect, useRef, useState } from 'react'; -import { Animated, StyleSheet, Text, View } from 'react-native'; -import { useReducedMotion } from 'react-native-reanimated'; -import { darkColors, fontFamily, fontSize, radii, spacing } from '@/theme'; - -const CYAN = darkColors.primary; -const BG = darkColors.background; -const DIM = 'rgba(0,229,255,0.35)'; -const BORDER = darkColors.border; - -interface Props { - isLoading: boolean; - isSolanaMobile: boolean; - nickname: string; - overlayOpacity: Animated.Value; - enteringOpacity: Animated.Value; - statusOpacity: Animated.Value; - nicknameOpacity: Animated.Value; - btnOpacity: Animated.Value; -} - -function LoadingDots() { - const dots = [useRef(new Animated.Value(0.15)).current, useRef(new Animated.Value(0.15)).current, useRef(new Animated.Value(0.15)).current]; - const reduceMotion = useReducedMotion(); - useEffect(() => { - // a11y: under "reduce motion" hold dots at mid-opacity. The "AWAITING - // WALLET APPROVAL / GENERATING SECURE KEYPAIR" label already conveys - // progress for screen-reader and motion-sensitive users. - if (reduceMotion) { - dots.forEach(d => d.setValue(0.55)); - return; - } - const anim = Animated.loop(Animated.stagger(100, dots.map(d => - Animated.sequence([ - Animated.timing(d, { toValue: 1, duration: 160, useNativeDriver: true }), - Animated.timing(d, { toValue: 0.15, duration: 160, useNativeDriver: true }), - ]) - ))); - anim.start(); - return () => anim.stop(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reduceMotion]); - return ( - - {(['d0', 'd1', 'd2'] as const).map((k, i) => )} - - ); -} - -export const LoadingOverlay = memo(function LoadingOverlay({ - isLoading, isSolanaMobile, nickname, - overlayOpacity, enteringOpacity, statusOpacity, nicknameOpacity, btnOpacity, -}: Readonly) { - const cursor = useRef(new Animated.Value(1)).current; - const bgOpacity = useRef(new Animated.Value(0)).current; - const [mounted, setMounted] = useState(false); - const reduceMotion = useReducedMotion(); - - // bg fade in/out — independent of parent element animations - useEffect(() => { - if (isLoading) { - setMounted(true); - Animated.timing(bgOpacity, { toValue: 1, duration: 150, useNativeDriver: true }).start(); - } else { - Animated.timing(bgOpacity, { toValue: 0, duration: 250, useNativeDriver: true }).start(() => { - setMounted(false); - }); - } - }, [isLoading, bgOpacity]); - - useEffect(() => { - // a11y: skip the terminal cursor blink under "reduce motion" — keep the - // underscore visible so the "@nickname_" handle still reads correctly. - if (reduceMotion) { - cursor.setValue(isLoading ? 1 : 0); - return; - } - const blink = Animated.loop(Animated.sequence([ - Animated.timing(cursor, { toValue: 0, duration: 260, useNativeDriver: true }), - Animated.timing(cursor, { toValue: 1, duration: 260, useNativeDriver: true }), - ])); - if (isLoading) blink.start(); else cursor.setValue(0); - return () => blink.stop(); - }, [isLoading, cursor, reduceMotion]); - - if (!mounted) return null; - - return ( - - - - - {isSolanaMobile ? 'CONNECTING' : 'ENTERING MESH'} - - - {isSolanaMobile ? '[ WALLET ]' : '[ IDENTITY ]'} - - - - @{nickname} - - _ - - - - - - {isSolanaMobile ? 'AWAITING WALLET APPROVAL' : 'GENERATING SECURE KEYPAIR'} - - - - - - ); -}); - -const S = StyleSheet.create({ - bg: { ...StyleSheet.absoluteFillObject, backgroundColor: BG, zIndex: 50 }, - center: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }, - card: { width: '100%', backgroundColor: darkColors.surface1, borderRadius: radii['2xl'], borderWidth: 1, borderColor: BORDER, paddingVertical: 44, paddingHorizontal: spacing[8], alignItems: 'center', gap: spacing[5] }, - label: { fontFamily: fontFamily.sansMd, fontSize: fontSize.xs, color: DIM, letterSpacing: 4, textTransform: 'uppercase' }, - status: { fontFamily: fontFamily.sansMd, fontSize: fontSize.xs, color: CYAN, letterSpacing: 4, opacity: 0.5 }, - handleRow: { flexDirection: 'row', alignItems: 'flex-end', marginTop: spacing[2], maxWidth: '100%', flexShrink: 1 }, - nickname: { fontFamily: fontFamily.sansMd, fontSize: fontSize['3xl'], color: CYAN, letterSpacing: 2, fontWeight: '700', flexShrink: 1, minWidth: 0 }, - cursor: { fontFamily: fontFamily.sansMd, fontSize: fontSize['3xl'], color: CYAN, fontWeight: '700', marginBottom: 3 }, - divider: { width: 40, height: 0.5, backgroundColor: darkColors.borderStrong, marginVertical: spacing[2] }, - bottomRow: { alignItems: 'center', gap: 14 }, - dotsRow: { flexDirection: 'row', gap: 10 }, - dot: { width: 6, height: 6, borderRadius: radii.full, backgroundColor: CYAN }, - detail: { fontFamily: fontFamily.sansMd, fontSize: fontSize.xs, color: DIM, letterSpacing: 2.5, textAlign: 'center' }, -}); diff --git a/mobile_app/components/onboarding/RadioStep.tsx b/mobile_app/components/onboarding/RadioStep.tsx new file mode 100644 index 00000000..d57fbfad --- /dev/null +++ b/mobile_app/components/onboarding/RadioStep.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useState } from 'react'; +import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { DepthButton, Icon } from '@/components/primitives'; +import { fontSize, radii, spacing, useTheme } from '@/theme'; +import { + type BLEPermissionStatus, + requestBLEPermissions, +} from '@/src/utils/blePermissions'; + +type Props = Readonly<{ onDone: (granted: boolean) => void }>; + +/** + * Bluetooth rationale BEFORE the OS prompts. Without this, a fresh install's + * first impression is Android's location + nearby-devices dialogs with zero + * context (the autostart in LxmfContext now defers prompting to this step). + */ +export function RadioStep({ onDone }: Props) { + const { colors, fontFamily } = useTheme(); + const insets = useSafeAreaInsets(); + const [status, setStatus] = useState(null); + const [requesting, setRequesting] = useState(false); + + const request = useCallback(async () => { + if (requesting) return; + setRequesting(true); + try { + const result = await requestBLEPermissions(); + setStatus(result); + if (result === 'granted' || result === 'not_required') onDone(true); + } finally { + setRequesting(false); + } + }, [onDone, requesting]); + + const denied = status === 'denied' || status === 'never_ask_again'; + + return ( + + + + + + + + + + The mesh + + + Turn on the mesh radio + + + anonmesh finds nearby phones over Bluetooth — that's how messages + travel without internet. + {Platform.OS === 'android' + ? ' Android will ask to let anonmesh find nearby devices, and for ' + + 'location access — Android requires it for Bluetooth scanning. ' + + 'anonmesh never reads or stores your location.' + : ''} + + + {denied && ( + + + + {status === 'never_ask_again' + ? 'Radio stays off. You can enable it any time from the Peers tab or system settings.' + : 'Radio stays off for now. The Peers tab can ask again whenever you’re ready.'} + + + )} + + + + onDone(false) : request} + size="lg" + tone="cyan" + variant="primary" + style={S.footerButton} + /> + {!denied && ( + onDone(false)} + hitSlop={8} + style={S.later} + accessibilityRole="button" + > + + Not now + + + )} + + + ); +} + +const S = StyleSheet.create({ + root: { flex: 1 }, + content: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing[7], + }, + iconGlow: { + alignItems: 'center', + justifyContent: 'center', + width: 148, + height: 148, + borderRadius: radii.full, + marginBottom: 28, + }, + iconShell: { + alignItems: 'center', + justifyContent: 'center', + width: 96, + height: 96, + borderRadius: radii.md, + borderWidth: 1, + }, + kicker: { + fontSize: fontSize.sm, + letterSpacing: 1.5, + marginBottom: spacing[3], + textTransform: 'uppercase', + }, + title: { + fontSize: fontSize['3xl'], + lineHeight: 36, + marginBottom: spacing[5], + textAlign: 'center', + }, + body: { + fontSize: fontSize.lg, + lineHeight: 26, + maxWidth: 360, + textAlign: 'center', + }, + deniedTile: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing[3], + marginTop: spacing[6], + paddingHorizontal: spacing[5], + paddingVertical: 12, + borderRadius: radii.lg, + borderWidth: 0.5, + maxWidth: 380, + }, + deniedText: { fontSize: fontSize.sm, lineHeight: 20, flex: 1 }, + footer: { + paddingHorizontal: spacing[6], + paddingTop: spacing[3], + gap: spacing[4], + }, + footerButton: { width: '100%' }, + later: { + alignItems: 'center', + minHeight: 36, + justifyContent: 'center', + }, + laterText: { fontSize: fontSize.sm }, +}); diff --git a/mobile_app/components/onboarding/ScreenFade.tsx b/mobile_app/components/onboarding/ScreenFade.tsx new file mode 100644 index 00000000..11a19538 --- /dev/null +++ b/mobile_app/components/onboarding/ScreenFade.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useRef } from 'react'; +import { Animated, type ViewStyle } from 'react-native'; + +// Dissolves its children in on mount with a gentle rise — used between +// onboarding stages so story → connect → backup → radio reads as one +// continuous piece instead of hard component swaps. +export function ScreenFade({ + children, + style, + rise = 10, + duration = 360, +}: Readonly<{ + children: React.ReactNode; + style?: ViewStyle | ViewStyle[]; + rise?: number; + duration?: number; +}>) { + const t = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(t, { + toValue: 1, + duration, + useNativeDriver: true, + }).start(); + }, [t, duration]); + + return ( + + {children} + + ); +} diff --git a/mobile_app/components/onboarding/index.ts b/mobile_app/components/onboarding/index.ts index d22ee2fa..7b48330e 100644 --- a/mobile_app/components/onboarding/index.ts +++ b/mobile_app/components/onboarding/index.ts @@ -1,6 +1,7 @@ -export { AsciiBackground } from './AsciiBackground'; -export { GlowOrbs } from './GlowOrbs'; export { SolanaIcon } from './SolanaIcon'; export { CTAButtons } from './CTAButtons'; -export { LoadingOverlay } from './LoadingOverlay'; +export { IntroHero } from './IntroHero'; +export { BackupStep } from './BackupStep'; +export { RadioStep } from './RadioStep'; +export { ScreenFade } from './ScreenFade'; export { generateNickname } from './constants'; diff --git a/mobile_app/context/LxmfContext.tsx b/mobile_app/context/LxmfContext.tsx index e0143b64..969eef5a 100644 --- a/mobile_app/context/LxmfContext.tsx +++ b/mobile_app/context/LxmfContext.tsx @@ -18,7 +18,7 @@ import { } from '@magicred-1/react-native-lxmf'; import { generateNickname } from '@/components/onboarding/constants'; import type { NetworkMode } from '@/src/infrastructure/network/types'; -import { requestBLEPermissions } from '@/src/utils/blePermissions'; +import { checkBLEPermissions } from '@/src/utils/blePermissions'; import { eventsAfter, highestEventId } from '@/src/utils/eventsAfter'; import { collectPeerMessages } from '@/src/services/peerMessages'; import { activeConversationRef } from '@/hooks/activeConversation'; @@ -513,6 +513,8 @@ interface LxmfCtxValue { /** Start BLE radio. For LoRa: pair RNode in OS BT settings first, then call this. */ startBLE: () => Promise; stopBLE: () => Promise; + /** Call after a surface wins the BLE permission prompt — retries node autostart. */ + grantRadioConsent: () => void; getStatus: () => LxmfNodeStatus | null; getBeacons: () => Beacon[]; fetchMessages: (limit?: number) => StoredMessage[]; @@ -613,6 +615,12 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode const startingRef = useRef(false); const autostartTimerRef = useRef | null>(null); + // Bumped by the surfaces that own the BLE permission prompt (onboarding's + // RadioStep, NodesScreen's enable affordance) right after a grant, so the + // check-only autostart below gets another pass and brings the node up. + const [radioConsentTick, setRadioConsentTick] = useState(0); + const grantRadioConsent = useCallback(() => setRadioConsentTick(t => t + 1), []); + useEffect(() => { if (!isNativeAvailable || isRunning || startingRef.current || displayName === null || !identityHydrated) return; let cancelled = false; @@ -620,7 +628,13 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode autostartTimerRef.current = setTimeout(() => { if (cancelled || isRunning || startingRef.current) return; startingRef.current = true; - requestBLEPermissions().then(async perm => { + // Check-only: autostart never triggers the OS permission dialogs. The + // first prompt belongs to a rationale surface (onboarding's RadioStep, + // or the Peers tab's enable affordance) — a fresh install's first + // impression must not be Android's location dialog with zero context. + // Those surfaces call grantRadioConsent() after a grant, which bumps + // radioConsentTick and re-runs this effect. + checkBLEPermissions().then(async perm => { if (cancelled || (perm !== 'granted' && perm !== 'not_required')) return false; if (isBeacon) { const keypairHex = await secureGet(SecureKeys.BEACON_KEYPAIR_HEX); @@ -656,7 +670,7 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode interaction.cancel(); }; }, [isNativeAvailable, isRunning, start, displayName, identityHydrated, storedIdentity, isBeacon, - beaconKeypairReady, setBeaconKeypair, setBeaconSolanaRpc, setPropagationNode]); + beaconKeypairReady, setBeaconKeypair, setBeaconSolanaRpc, setPropagationNode, radioConsentTick]); // 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. @@ -1057,6 +1071,7 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode send: handleSend, broadcast: lxmf.broadcast, startBLE: handleStartBLE, + grantRadioConsent, stopBLE: handleStopBLE, getStatus: lxmf.getStatus, getBeacons: lxmf.getBeacons, @@ -1110,7 +1125,7 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode lxmf.broadcast, lxmf.getStatus, lxmf.getBeacons, lxmf.setLogLevel, lxmf.bleUnpairedRNodeCount, lxmf.getNusUnpairedRNodes, lxmf.pairNusRNode, lxmf.getConnectedRNodes, lxmf.unpairNusRNode, syncPropagation, - lxmf.beaconRpc, lxmf.beaconBroadcastRpc, lxmf.beaconRpcWait]); + lxmf.beaconRpc, lxmf.beaconBroadcastRpc, lxmf.beaconRpcWait, grantRadioConsent]); return ( diff --git a/mobile_app/package-lock.json b/mobile_app/package-lock.json index 91a7107a..fe6de60c 100644 --- a/mobile_app/package-lock.json +++ b/mobile_app/package-lock.json @@ -54,6 +54,7 @@ "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", + "expo-video": "~3.0.16", "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", @@ -8665,6 +8666,17 @@ "expo": "*" } }, + "node_modules/expo-video": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/expo-video/-/expo-video-3.0.16.tgz", + "integrity": "sha512-H1HlxcHGomZItqisGfW3YL/G9BHtNBfVSimDJcLuyxyU87wFnV8loO9tCjuhufkfh/aTa2sW5BYAjLjg9DvnBQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-web-browser": { "version": "15.0.11", "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz", diff --git a/mobile_app/package.json b/mobile_app/package.json index c608dd18..3ddc2823 100644 --- a/mobile_app/package.json +++ b/mobile_app/package.json @@ -63,6 +63,7 @@ "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", + "expo-video": "~3.0.16", "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/mobile_app/screens/NodesScreen.tsx b/mobile_app/screens/NodesScreen.tsx index 45698009..68e4ed2f 100644 --- a/mobile_app/screens/NodesScreen.tsx +++ b/mobile_app/screens/NodesScreen.tsx @@ -16,7 +16,7 @@ import { PulseDot } from '@/components/ui/PulseDot'; import { ScreenHeader } from '@/components/ui'; import { FILTERS } from '@/components/nodes/constants'; import type { NodeData, Filter } from '@/components/nodes/types'; -import { requestBLEPermissions, type BLEPermissionStatus } from '@/src/utils/blePermissions'; +import { checkBLEPermissions, requestBLEPermissions, type BLEPermissionStatus } from '@/src/utils/blePermissions'; // Converts a peer to NodeData WITHOUT latency — stable identity for MeshMap. // Latency is added separately for the list so MeshMap topology doesn't re-layout on every timer tick. @@ -44,7 +44,7 @@ function peerToMapNode(p: LxmfPeer, mode: NetworkMode): NodeData { export default function NodesScreen() { const { colors } = useTheme(); - const { isRunning, isNativeAvailable, isAnnouncing, bleActive, peers, startBLE } = useLxmfContext(); + const { isRunning, isNativeAvailable, isAnnouncing, bleActive, peers, startBLE, grantRadioConsent } = useLxmfContext(); const { mode } = useNetworkMode(); const router = useRouter(); @@ -69,21 +69,40 @@ export default function NodesScreen() { // silently kills peer discovery forever — the "scanning forever, no one here" // trap with no exit (off-grid #1). const [blePerm, setBlePerm] = useState(null); + + // Shared post-permission path: if the node is already up, flip the radio on; + // if it parked at the permission gate (check-only autostart), nudge it. + const bringRadioUp = useCallback(() => { + if (isRunning) startBLE(); + else grantRadioConsent(); + }, [isRunning, startBLE, grantRadioConsent]); + + // Explicit affordance — the only place this screen may show the OS dialogs. const enableBle = useCallback(async () => { if (bleActive) return; // already started — don't re-trigger GATT registration const permissionStatus = await requestBLEPermissions(); setBlePerm(permissionStatus); if (permissionStatus !== 'granted' && permissionStatus !== 'not_required') return; - startBLE(); - }, [bleActive, startBLE]); + bringRadioUp(); + }, [bleActive, bringRadioUp]); + + // Focus path is check-only: users who already granted get the radio back + // without ever seeing a prompt; users who haven't keep the explicit button. + const ensureBleIfPermitted = useCallback(async () => { + if (bleActive) return; + const permissionStatus = await checkBLEPermissions(); + setBlePerm(permissionStatus); + if (permissionStatus !== 'granted' && permissionStatus !== 'not_required') return; + bringRadioUp(); + }, [bleActive, bringRadioUp]); useFocusEffect(useCallback(() => { if (!isNativeAvailable) return undefined; const task = InteractionManager.runAfterInteractions(() => { - enableBle(); + ensureBleIfPermitted(); }); return () => task.cancel(); - }, [isNativeAvailable, enableBle])); + }, [isNativeAvailable, ensureBleIfPermitted])); // Stable node identity: only re-creates when peers change, not on timer ticks. // MeshMap receives this — topology layout only runs when peer set actually changes.