fix(lxmf): event-lag mitigations + BLE peer online status#62
Merged
Conversation
* V3 magic branch (#17) * chore(deps): bump react-native-lxmf to ^0.2.5 * fix(ux): back-navigation lock, exit toast, dedup msg keys, inline new message - (tabs)/_layout: BackHandler exits app on Android with "press back again to exit" toast (spring slide-up, themed, 2s window); gestureEnabled: false blocks iOS swipe-back - _layout: gestureEnabled: false on (tabs) Stack.Screen; fix duplicate onPress prop - onboarding: instant redirect (0ms) when already connected on mount — prevents LoadingOverlay flash on back-nav; 2200ms stamp delay preserved for fresh connects - PeersDrawer: collapse two-view nav into single unified component — hash input + QR always visible at top, peer list in contained ScrollView; remove NewConvoView split - MessagesScreen: replace Date.now() IDs with monotonic nextId() — fixes duplicate key warning when multiple messages arrive in the same millisecond * fix(tabs): dynamic tab bar height respects system nav inset useSafeAreaInsets() — height = 56 + insets.bottom, paddingBottom = insets.bottom. Fixes overlap on Android gesture-nav and 3-button-nav phones. * feat(wallet): bento grid redesign with balance, network, peers, activity tiles Replaces flat scrollable layout with bento-style grid. Balance tile shows SOL + token breakdown with hide toggle. Network tile shows online/mesh/isolated state. Peers tile shows total peer count + online count. Activity tile renders tx history with direction-aware formatting. Fixes font family fallback on Android by using sansBold/sansSb variants directly instead of fontWeight overrides. * feat(ui): bento grid redesign for wallet, receive, and send flow WalletScreen: activity tile fills remaining screen height, pigeon animation for online mode (RN Animated, no reanimated SVG), NetworkMode import direct from hook, online color unified to cyan. ReceiveScreen: full bento-grid redesign matching WalletScreen — tile cards, kicker header, inline share/copy action tiles, stealth note, neutral/cyan segmented control tones. Send flow: SendScaffold, RecipientPicker, AmountKeypad, ReviewCard, SuccessCard all restyled with bento tiles, consistent kicker+title headers, Feather icons, no glass hooks. SegmentedControl: adds neutral tone for non-accent tab states. * feat(nodes): wire BeaconRegistry to real isBeacon state and live reachable count - LxmfContext: add isBeacon state (persisted), setBeaconMode (stop+restart with flag) - BeaconRegistry: replace local active state with context isBeacon/setBeaconMode - BeaconRegistry: reachableCount from live beacons + online peers instead of hardcoded 3 - BeaconRegistry: disable register button when offline, show "REQUIRES INTERNET" - storage: add BEACON_MODE pref key * feat(nodes): detect anonmesh beacon peers from app_data prefix, show BEACON badge - LxmfPeer: add isBeaconNode flag - applyAnnounceEvent: detect 'anonmesh::beacon::v1\0<name>' prefix, extract display name after null separator - mergeBeacon: always mark beaconDiscovered peers as isBeaconNode - peerToMapNode: pass beacon flag to NodeData - NodeRow: show BEACON pill next to handle when n.beacon is true * fix(peers): full-width search bar with larger tap target in PeersDrawer * feat(chat): media sending + queue state indicators on message bubbles - Composer: image picker button (expo-image-picker), MediaPayload callback - MediaBubble: display received/sent images, tap-to-fullscreen lightbox - types: MediaMsg type, AnyMsg union extended - MessagesScreen: handleMedia sends base64-encoded image over LXMF - parseStructuredMsg: decode incoming {t:'media'} envelope to MediaBubble - MessagesScreen: seqStates map tracks queued/delivered/failed per-seq - MessageBubble: sendState prop with icon+label (sent/queued/delivered/failed) - Pending banner when messages are queued waiting for peer - app.json: NSPhotoLibraryUsageDescription + expo-image-picker plugin * feat(chat): migrate to lxmf module API, per-peer threads, file attachments, error banner API migration (messageReceived): - Drop old msgpack wire decode (decodeLxmfSender/decodeLxmfContent/e.content) - Read e.source (sender hash), e.body (base64 UTF-8), e.image, e.files directly - Inbound images now come via e.image → MediaBubble; removes JSON-body media hack - Inbound file attachments rendered as tappable rows with name + size in MessageBubble Media send: - Pass image as LxmfMedia 3rd arg to send() instead of JSON-encoded body - send/broadcast types updated in LxmfCtxValue to include media?: LxmfMedia Per-peer thread persistence: - threadsRef (Map<destHash, AnyMsg[]>) stores messages per conversation - pickPeer saves current thread before switching, restores saved or inits fresh - Incoming messages for non-active peer routed to their thread directly QR scan UX: - Scan result immediately starts conversation (removed intermediate contact card + button) Composer/keyboard: - Composer bar and field padding reduced (fits tighter) - Field borderRadius 99→12 (matches icon squircle style) - Android keyboard: softwareKeyboardLayoutMode=pan + KAV behavior=undefined on Android Error banner: - LxmfErrorBanner reads error from LxmfContext, floats above tab bar - Dismissible per distinct error string, auto-reappears on new error * fix(android): keyboard overlap — resize mode + hide tab bar on keyboard * fix(chat): remove dead try/catch, mark failed sends on bubble instead of sys message send() in useLxmf catches all native errors internally, sets lxmf.error, returns -1. The try/catch in sendMsg/handleMedia never fired. pendingRef retry logic was unreachable. - Remove pendingRef + announce-retry useEffect (dead code) - Remove try/catch from sendMsg and handleMedia - On seq === -1: write pseudoSeq (-msgId) into seqStates as 'failed' so the bubble shows the failed indicator; lxmf.error banner shows the actual reason - handleGridAction: drop unreachable .catch() * fix(notifications): use e.source directly, skip pickPeer when already in thread useMessageNotifications was decoding srcHash from e.content (old wire format) which doesn't exist in the new API — e.content was always empty string, so decodeLxmfSender returned null, srcHash fell back to e.source but via a broken path. Suppression check (srcHash === activeConversationRef) never matched → banner fired even for active peer. useFocusEffect was calling pickPeer unconditionally on notification tap, resetting the thread even when the peer was already active. Guard added. * fix(chat): treat seq=0 as success — only seq<0 is a send error useLxmf.send() returns -1 on error, passes through native seq otherwise. Native module may return seq=0 for queued-without-route messages that still get delivered. Previous check (seq > 0) falsely marked these as failed. Now: seq < 0 → failed bubble, seq > 0 → tracked with timer, seq = 0 → optimistic. * fix(chat+notifs): display names, thread routing, keyboard, lxmf bump - getDisplayName reads knownPeersRef directly (bypasses stale React state) - Notification dispatch deferred via setTimeout(0) so parent peer-tracking effect populates knownPeersRef before display names are resolved - Thread routing: flip condition to positive form; sync activePeerHexRef immediately in pickPeer to close race window on peer switch - useFocusEffect fallback uses getDisplayName instead of @hash prefix - Inline from resolution uses getDisplayName (ref) instead of lxmfPeers.find - keyboard: pan mode + KAV padding + safe-area spacer collapses on keyboard - bump @magicred-1/react-native-lxmf 0.2.25 → 0.2.26 * Polish mobile bottom navigation (#18) * feat: polish mobile bottom navigation * fix: keep polished tabs responsive --------- Co-authored-by: Excelsior <33706074+epicexcelsior@users.noreply.github.com>
- plugins/withAndroidForegroundService.js: config plugin writes LxmfForegroundService.kt (START_STICKY), LxmfServiceModule.kt (NativeModule start/stop), LxmfServicePackage.kt, patches MainApplication.kt, adds FOREGROUND_SERVICE + FOREGROUND_SERVICE_CONNECTED_DEVICE permissions + service declaration (foregroundServiceType=connectedDevice) - hooks/useBackgroundService.ts: starts service on mount, stops on unmount, Android-only - app/_layout.tsx: call useBackgroundService() in AppShell - app.json: register plugin; add fetch + processing to iOS UIBackgroundModes
…ore message arrives
- connect(): check pairNusRNode() boolean — false/throw → error msg, stay phase 1 - phase 2: poll getNusUnpairedRNodes() every 500ms until pairedMac disappears from list (= OS bond + GATT connected); 15s timeout → error back to phase 1 - show pairError in phase 1 hint text - remove fake 1800ms setTimeout that showed success regardless of outcome
- LxmfContext: add groups state, createGroup/joinGroup/leaveGroup/getGroupMembers, auto-register groups on node restart, route sendGroup for group dest addrs - PeersDrawer: CHANNELS section with JOIN/CREATE pill buttons, group rows with members icon, long-press to leave; separate DM peers from group peers - CreateGroupModal: bottom-sheet, input → done phase with QR code + copy rows - JoinGroupModal: bottom-sheet, prominent SCAN QR CODE button, manual addr+key entry - GroupMembersSheet: bottom-sheet listing active senders derived from message history - ChannelShareSheet: QR code + copy addr/key/pair for sharing channel join info - ThreadHeader: share-2 icon triggers ChannelShareSheet when active peer is a group - QRScannerModal: extend ScannedAddress with lxmf-group type, parse lxmf://group/… URIs - Composer: swap arrow-up → bird icon (MaterialCommunityIcons), fix safe-area bottom spacer background color to match bar (surface0) - MeshMap: expand by default on mount - lxmf bump 0.2.43 → 0.2.53
… README - PeersDrawer: SwipeableGroupRow with pan gesture — swipe left reveals red LEAVE action; removes onLongPress + users icon + onShowMembers prop - MessagesScreen: drop GroupMembersSheet, membersAddrHex state, onShowMembers - LoadingOverlay: rewrite from 267 → ~100 lines, drop passport stamp phase, same loading card + dots + cursor blink animation - Composer: bird icon (MaterialCommunityIcons) replaces arrow-up send button; safe-area spacer background fixed to surface0 - MeshMap: expanded by default on mount - README: project manifesto — messaging, channels, mesh networking, Solana wallet, identity/privacy, install, architecture, compatibility + banner
…GS crash fix MessagesScreen / PeersDrawer — full rewrite: - Removed slide drawer; list IS the screen when no peer selected (two-state) - Picking a peer slides chat in from right (spring); swiping right or tapping the back arrow slides it back out — same Reanimated withSpring animation - Unified conversation list (groups + DMs, unread-first sort, circle avatars) - JOIN / CREATE channel always visible; JOIN has solid border, CREATE has filled primary background — high contrast, no toggle needed - Hash input and search always visible, no toggle - SwipeableGroupRow: left-only gesture, velocity-aware snap, animated icon scale (interpolate 0.64→1.0 as you reveal), deeper red (#c0392b) NodesScreen: - Removed LINKED PEERS section; filter chips overlaid on MeshMap (absolute bottom) - BeaconRegistry fills remaining screen height (flex: 1) with beacon explanation MeshMap: - Removed floating EXPAND pill (was leaking past Animated.View overflow on Android, overlapping BeaconRegistry); header maximize-2 button still opens fullscreen - Full header row is now a Pressable for expand/collapse (wider tap target) - Chevron bumped to size 14, textSecondary color for better visibility Android FGS crash (SecurityException): - startForeground now passes FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE on API 34+ - Added CHANGE_NETWORK_STATE / CHANGE_WIFI_STATE to manifest to satisfy the "at least one of" requirement for connectedDevice type (auto-granted, no prompt)
…lease Settings: - Rename labels for clarity (pair with hardware, biometric lock, cellular fallback, rotate identity keys, export wallet key, message notifications) - Remove connection priority row MeshMap: - Fix node overlap: two-step layout enforces MIN_ARC_GAP (44px) per ring and CROSS_RING_GAP (36px) between adjacent rings - filterRow render prop: filter chips appear in both collapsed and fullscreen views; selStripBottom prop keeps node strip above chips - selStrip no longer covered by filter overlay in NodesScreen Security: - generateKeyHex: replace Math.random fallback with ExpoCrypto.getRandomBytes (expo-crypto was already a dep, fallback was silently insecure) CI: - Add .github/workflows/release-android.yml: push to v3 triggers expo prebuild → signing patch → assembleRelease → GitHub release with APK - Gitignore: add *.keystore rule to mobile_app/.gitignore
…+ in service template
- NoPeersScreen: 3 staggered sonar rings with app icon when no peers - Replaces PeersDrawer when peer list is empty; New/Join group chips shown - BLE permissions requested via useFocusEffect on MessagesScreen mount - PeersDrawer always receives full livePeers array (no undefined guard)
…tedDevice type requires at least one
…ice requires runtime BLE grants at startForeground() time which aren't held at launch
… modal Messages: - Always mount chat panel off-screen (translateX: screenW) so peer-pick transition is pure UI-thread animation with zero mount cost - Switch spring→withTiming (240ms easeOut in, 200ms easeIn out) for predictable snappy feel - Move GestureDetector to wrap only scroll+composer so ThreadHeader back Pressable receives taps on Android - Fix garbled text from external LXMF peers: decodeBody now tries base64 first, validates readability, falls back to raw UTF-8 Nodes: - PendingCosigns: new horizontal snap-scroll card carousel with dot indicators, screen-width-aware sizing, co-sign + reject actions - MeshMap: expose onExpandChange prop so NodesScreen hides filter chips when map is collapsed (no overlap) - BeaconRegistry: stake modal with amount stepper, rep-score + yield impact preview, biometrics CTA; auto-activates beacon on internet Wallet: - Bird icon (MaterialCommunityIcons "bird") on online/mesh status chip - Bird icon for empty recent-activity state - Fix unused useRef lint, extract netLabel nested ternary
…ents connectedDevice duplicate surviving across prebuilds
… TextInput - PeersDrawer: merge search + paste-hash inputs into one smart field — auto-detects hex hash (≥16 chars) vs search query; camera always visible, X clears on demand - MeshMap: replace selection ring + online dot with interface-type badge (wifi/bluetooth/radio-tower) top-right of each peer node; add DM button to selected-peer strip that navigates to Messages with that peer pre-opened - NodesScreen → MessagesScreen deep-link: router.navigate to /(tabs) with destHash+handle params; MessagesScreen picks them up via useLocalSearchParams + useFocusEffect and calls pickPeer - BeaconRegistry: stake stepper TextInput replaces static Text — live preview of rep/yield impact while typing; KeyboardAvoidingView wraps stake modal; bigger SOL icon (14→28), bigger hero icon (16→26), impact icon sizes 12→14 - PendingCosigns: moved to WalletScreen; fixes double-padding (H_PAD removed, WALLET_PAD=32 for cardW calc); compact card dimensions (padding 16→14, amount 34→26, actions 44→40)
…ipts Lowercases all user-visible AnonMesh -> anonmesh (8 strings): - tutorial slide title, header fallback, final CTA - receive.tsx + solanaPayUri.ts Solana Pay label/message defaults - MeshMap empty-state copy - blePermissions internal comment Adds two npm scripts that deep-link the tutorial route: - replay:tutorial:android (adb am start) - replay:tutorial:ios (xcrun simctl openurl) Lets any teammate replay the first-run tour on a running device without pm clear or rebuilding. No app-code changes.
Slide 1 (identity): identity-chip -> user Slide 2 (evidence/radio): signal -> radio (matches statLabel "Radio") Drops two custom SVGs in favor of Feather glyphs that read cleaner at the 42px tutorial size and align with icon style used elsewhere in Settings. Slide 3 (send) already used Feather.
…link
Demo affordances must not ship in the production bundle even as inert
dead code. EXPO_PUBLIC_DEMO_MODE was env-driven and could be flipped on
for any release build, leaking demo-time behavior to anyone holding
that APK.
- Remove src/utils/demoMode.ts (DEMO_MODE + DEMO_RECIPIENT_ADDRESS)
- RecipientPicker: read recipient from ?to= query param via
useLocalSearchParams; drop hardcoded prefill, demo fill button,
and orphan styles; tighten QR-soon alert copy
- ReviewCard: drop "Demo mode: devnet SOL only" banner + orphan styles
- Add demo:prefill-send:{android,ios} npm scripts wrapping the
anonmesh://send/recipient?to=<addr> deep link with the same demo
address as the previous flag
No regression on tab navigation: /send/recipient with no params still
opens to an empty address field (identical to DEMO_MODE-off behavior).
The component's own comment notes that horizontal padding is owned by WalletScreen's grid (paddingHorizontal: 16, mirrored by WALLET_PAD = 32 in the cardW math). The H_PAD reference on the wrapper View was dead code left over from an earlier refactor — at runtime it raises "Property 'H_PAD' doesn't exist" the moment the wallet tab mounts. Drop the inline paddingHorizontal entirely.
Composer's Pressables (grid, image, send/bird) sat inside the back-pan GestureDetector. Pan + RN Pressable both grab the touch responder, so the inner taps silently lost while TextInput (native, bypasses the JS responder) kept working — hence "keyboard send works, bird button doesn't, other icons feel like placeholders." Move Composer outside the GestureDetector so the swipe-back gesture only covers the message list. While here, bump composer icon sizes (15→18, 18→20) and button radii to align with the field, and add hitSlop=8 on each Pressable for less finicky taps on the 36pt targets.
Without this, the 1.4s "COPIED" badge survived a copy → background → resume cycle: capture-block dropped to pending, secret was scrubbed, but keyCopied state remained true. Next reveal flashed a stale COPIED badge on a freshly re-authenticated session. Also clear keyCopied at the start of authenticate() as belt-and-suspenders in case a user retries faster than the 1.4s timeout. Add a recipient-only URI test to make the no-amount path explicit, and reword the multi-separator test comment so it doesn't claim locale-grouped numbers (1.234,56) are universally invalid — they aren't, but the receive screen never feeds them.
…tive Add @gorhom/bottom-sheet@^5 as the canonical bottom-sheet engine. Wraps it as components/primitives/BottomSheet.tsx exposing our visual chrome (handle indicator, glass surface, primary border accent, backdrop tied to drag progress) with a declarative visible/onClose API consumers already know. What gorhom buys: - Pan-from-anywhere on the sheet (Apple convention; not handle-only) - Native-thread gesture + animation (no JS lag under load) - Scroll-to-dismiss handoff for sheets with internal scroll (BottomSheetScrollView/FlatList re-exported from this module) - Flick-to-dismiss with velocity projection - Snap points (omit prop = dynamic content-fit sizing) - Keyboard-interactive layout for sheets with TextInput - Modal portal — render anywhere in the tree, sheet floats above Wired BottomSheetModalProvider inside GestureHandlerRootView in the root layout so every screen can present sheets without local provider. BottomSheetHandleBar exported as a standalone bar for surfaces that can't move to AppBottomSheet yet (route-style modals like the receive screen). Visual matches the gorhom handle indicator.
Two surfaces migrate off bespoke gesture/animation scaffolding onto the canonical primitives in this PR: TokenPicker (components/send/TokenPicker.tsx): - Wraps content in <AppBottomSheet visible onClose>; drops Modal, GestureHandlerRootView, GestureDetector, Animated.View, Pressable backdrop, in-line grab bar, panGesture, dismiss callback, DISMISS_DISTANCE/DISMISS_VELOCITY constants, sheetStyle/translateY, useGlass. - Result: -120 LOC; Apple-feel pan-from-anywhere, scroll-handoff if it ever grows scrollable content, keyboard-interactive for free. Receive route (app/receive.tsx): - Was using legacy PanResponder (JS-thread → laggy under load on Seeker). Replaced with react-native-gesture-handler Pan + Reanimated worklet writes — translateY now updates on the UI thread so the sheet follows the finger 1:1. - Visual handle swapped to <BottomSheetHandleBar/> from the primitive module so the receive route matches the gorhom-rendered indicator used elsewhere. - Velocity threshold corrected (was 0.8 in PanResponder dy/ms units; gesture-handler uses px/s — now 800 to match other surfaces). - Inline GrabHandle helper deleted. - Receive stays a Stack.Screen route (presentation: 'modal') for now; conversion to a true gorhom BottomSheetModal mounted from the wallet bento is a future refactor.
Dynamic content sizing in @gorhom/bottom-sheet v5 has a measurement
race on first present — BottomSheetView reports 0 height before
children mount, the modal opens at height 0 then never measures again.
TokenPicker presented but rendered invisible.
Lock to a single 85% snap point by default with enableDynamicSizing
disabled. Consumers can pass snapPoint='60%' etc. for a different
height. index={0} explicit so present() always opens to the first
(and only, by default) snap.
Future work: revisit dynamic sizing with onLayout callback once we
have a sheet that genuinely needs content-fit height (TxDetailModal
post-merge — variable content based on tx fields).
Gorhom's BottomSheetModal was firing present() but rendering invisible in our setup — likely a v5 dynamic-sizing/portal interaction with Reanimated 4 + expo-router. Couldn't reproduce the working state from the docs in our tree. Pivoting to a primitive built on react-native-gesture-handler + Reanimated directly so we control every piece and can debug end-to-end. What the new AppBottomSheet does: - Native RN Modal as the platform overlay (transparent + animationType=none). - gesture-handler Pan with activeOffsetY([12, screenHeight]) so the sheet only claims the gesture on downward intent past 12pt — inner Pressables/inputs still receive their taps. - Reanimated worklet writes translateY directly during drag so the sheet follows the finger 1:1 on the UI thread (no JS lag). - Spring physics damping 22 / stiffness 250 (open) and 320 (back) for a snappy Apple-adjacent feel. - Velocity-aware dismiss: drag past 120pt or fling > 800px/s closes; short release springs back. - Backdrop opacity interpolates 0 → 0.7 with translateY progress so the dim fades naturally as the sheet leaves. - Internal mounted state lags the visible prop on close so the slide- down animation finishes before the Modal unmounts (without it the sheet would just snap off-screen). - Split dismiss-pattern: dismiss-Pressable is flex:1 above the sheet (no overlap with sheet area), backdrop dim is a separate pointerEvents:none Animated.View. Same fix pattern as TxDetailModal post-debug — avoids absoluteFill-sibling responder claims that silently swallow nested DepthButton/Pressable taps. Removed BottomSheetModalProvider from app/_layout.tsx since it's no longer needed (and may have been interfering with the wallet-screen QR button's native Modal — to be verified on rebuild). Removed @gorhom/bottom-sheet from package.json. Removed the BottomSheetScrollView/BottomSheetFlatList re-exports from the primitives index since they were gorhom-specific. AppBottomSheet + BottomSheetHandleBar remain — same public API as before.
Two bugs from the previous primitive build:
1. activeOffsetY([12, SCREEN_HEIGHT]) — array form means "activate
when Y translation is OUTSIDE this range", i.e. < 12 OR > screen
height. The < 12 case fires on UPWARD pan, not downward — exactly
the opposite of what a pull-down dismiss needs. Switched to
activeOffsetY(12) (single positive number = downward past 12pt
activates).
2. withSpring(0, { damping: 22, stiffness: 250 }) over SCREEN_HEIGHT
travel was underdamped (ratio ~0.7) so the sheet overshot past
the rest position and oscillated — "shoots way up, slow." Apple's
modal sheet open isn't a spring; it's a timing curve. Switched
open + close to withTiming with Easing.out/in cubic (320ms / 220ms).
Spring kept only for snap-back after a partial interactive drag —
short distance there, no overshoot risk, feels natural.
Receive screen still uses single-number activeOffsetY (was already
correct), so it gestures down. Lag user reported there is the native
modal back-animation conflicting with our local dismiss animation —
deferring that fix to a follow-up since it requires changing the
route's presentation config.
…blue line Two more bugs from device smoke: 1. TokenPicker still couldn't gesture down even with activeOffsetY(12) because RN Modal renders contents in a SEPARATE NATIVE VIEW TREE (Window/Dialog on Android, separate UIWindow on iOS). The app-root GestureHandlerRootView at app/_layout.tsx does NOT propagate into that tree, so GestureDetector inside the modal saw no gestures. Documented in react-native-gesture-handler README under "Using inside Modal". Added a GestureHandlerRootView wrapping the Modal contents — gesture context now lives inside the modal's native tree where children can find it. Receive screen worked because it's a Stack.Screen route (same gesture-handler context as the rest of the app), not an RN Modal. 2. "Weird blue line at top" of the sheet was borderTopWidth: 1 with borderColor defaulting to colors.borderStrong, which is rgba(0,229,255,0.28) — cyan. Switched default to colors.border (subtle) and dropped width to StyleSheet.hairlineWidth so the divider is barely visible, not a feature accent. Also tightened the docstring to reflect the actual behavior (timing for open/close, spring only for snap-back) and pulled the GestureHandlerRootView wrap requirement up as critical wiring so future maintainers see it before they break it. Standard primitive: every new sheet in the app should use AppBottomSheet. No per-screen ad-hoc gesture/animation rewrites.
Two changes targeting the 'tiny bit of lag/jitter' user reported on
the receive screen pan-to-dismiss:
1. Sequence the dismiss animations. Previously: pan releases past
threshold → translateY = withTiming(800) AND router.back() fire
simultaneously. Our local timing animates inner content down to
800px while the native back-animation slides the WHOLE screen
from full position to off-screen — two transforms compound into
visible jitter.
Now: withTiming completion callback fires router.back. By the
time native back kicks in, our inner is already off-screen, so
the native animation runs invisibly. One animation per frame.
2. renderToHardwareTextureAndroid + collapsable={false} on the outer
Animated.View. The receive screen has a non-trivial subtree
(QR svg, segmented control, two action buttons, an input). On
Android, transforming that subtree per-frame asks the GPU to
recomposite the whole layer. The hardware-texture hint caches
the view as a GPU texture so per-frame translateY is cheap.
collapsable={false} prevents Android's view-tree optimizer from
collapsing the wrapper layer (which would defeat the texture
cache).
No behavior change — same dismiss threshold (120pt or 800px/s),
same spring snap-back. Just smoother on Android.
User flagged: after our pan-to-dismiss slides receive content off- screen smoothly, a black screen still slid down behind it. Cause: the Stack-screen route container has a default dark background. After our inner Animated.View timing reached 800px, the container itself was still full-screen with its own (dark) background. router.back then fired native back animation — sliding the empty dark container down over the wallet screen. Visual: extra black flash. Switch presentation from 'modal' to 'transparentModal'. Container background is transparent → after our inner content slides off, the empty container is invisible → native back animation runs over a transparent layer → user sees wallet screen behind during back, no black flash. Receive content keeps its own backgroundColor, so the visible area looks identical during entry and use.
User reported a 'little black box at bottom that waits' after swipe-
down dismiss, even with transparentModal. Cause: the native back
animation was still slid the empty Stack-screen container down behind
our animation, and any height mismatch (window-height translate vs
screen-height container) left a sliver of content visible at the
bottom edge.
Solid architecture: receive owns ALL motion via Reanimated; the route
itself runs zero native animations.
Three changes:
1. app/_layout.tsx receive Stack.Screen options:
- presentation: 'modal' → 'transparentModal' (transparent container)
- animation: 'slide_from_bottom' → 'none' (no native enter/exit)
- contentStyle: { backgroundColor: 'transparent' } (the screen's
own content layer is also transparent)
The route is invisible at every phase; only our own Animated.View
has a backgroundColor and only it ever moves.
2. app/receive.tsx initialization:
- dragY initial value: 0 → SCREEN_HEIGHT (start off-screen)
- useEffect on mount: withTiming(0, ease-out cubic, 320ms) — slides
the content UP from off-screen into rest position. Replaces the
native slide_from_bottom we just disabled.
- Dismiss timing already sequenced via callback — slides DOWN to
SCREEN_HEIGHT then fires router.back which now unmounts instantly
(animation:'none'), invisible to the user.
3. SCREEN_HEIGHT source: Dimensions.get('window') → Dimensions.get('screen')
in BOTH receive.tsx AND BottomSheet.tsx primitive. 'window' excludes
status bar; 'screen' is the full device extent. For Android edge-to-
edge content under the gesture nav, 'window' translate left a
sliver below the safe area = the 'little black box that waits'.
Result: from the user's POV, taps Receive → screen slides up, can pan
freely, swipes down → screen slides down, gone. No native back
animation, no empty container, no black flash, no sliver. Single
animation source per surface — receive owns its frame, BottomSheet
primitive owns its frame, identical curves and timings.
… through animated dismiss BottomSheet.tsx Backdrop opacity interpolation now clamps. A fast flick can park translateY past SCREEN_HEIGHT before the close timing settles; without clamping, opacity extrapolates negative. Reanimated/RN tolerate it today but the contract is undefined. receive.tsx Extract animateAndDismiss helper that runs the same withTiming(SCREEN_HEIGHT, TIMING_CLOSE) → router.back sequence the gesture's onEnd already used. The header X button now calls this helper instead of router.back() directly. Without this, header-X dismiss triggered the route's animation:'none' on a still-on-screen content view, producing a black flash. All non-gesture dismiss paths now share one animation curve.
Replaces the placeholder "QR scan coming soon" alert with the existing QRScannerModal already used by PeersDrawer and JoinGroupModal. Adds a parseSolanaPayUri helper to src/services/solanaPayUri.ts as the single source of truth for both bare base58 and full Solana Pay URI decoding (amount, spl-token, label, message, memo, reference). QRScannerModal's solana variant now carries the parsed params instead of just the address, and the inline parsing is delegated to the new helper so future consumers don't drift. RecipientPicker uses the params: on scan, it sets the recipient address and — when an amount is requested without an spl-token — pushes straight to the amount step with the amount prefilled, so the user lands on the keypad ready to confirm. spl-token requests are intentionally ignored for amount prefill: the send flow is currently SOL-only (TokenPicker.isSendable filters all SPL entries pending the legacy transferInstruction T22 fix), so we honor the recipient but let the user choose the amount in SOL. AmountKeypad reads the optional amount param and falls back to "0" so manual continue from the recipient step is unchanged.
When a user denies camera permission with "don't ask again", the only path to grant is via OS Settings. expo-camera's useCameraPermissions only re-checks on mount + on requestPermission(), so returning from Settings with a fresh grant left the modal stuck on the denial UI until the app was killed and reopened. useCameraPermissions returns a third element — a silent getter that reads OS state without showing the prompt. Subscribe to AppState while the scanner is visible and call the getter on each foreground; grants made in Settings now reflect live without a re-mount, denials stay silent (no prompt loop), and there's no effect when the scanner is closed.
Builder had fixture coverage but the new parser shipped against the camera path with only ad-hoc fixtures. Adds testParseSolanaPayUri to the existing tier-0 services validator (no new test infra) covering the four areas reviewers flagged: bare base58, solana: URIs with and without params, invalid inputs, and spl-token / reference filtering. Also asserts the silent-drop policy on amount edge cases (zero, negative, NaN, 10-decimal overflow) — recipient still surfaces so the keypad picks up the address while the user enters the amount manually.
Co-sign signing flow is not yet wired but the panel previously rendered a fully-styled "Sign with Biometrics" CTA with a lock icon whose onPress simply removed the item from local state. That's theatre — a tap looks like authorization but produces no signature, no tx, no on-chain effect. This neuters the surface without removing the roadmap signal: - PendingCosigns: section labelled "MULTISIG CO-SIGNS" with a PREVIEW badge; both action buttons inside each card are now non-interactive (pointerEvents=none, dim opacity), the lock icon is removed, and the primary CTA reads "Preview — not yet active". - Empty state copy now says "Multisig co-signs not yet live" instead of the misleading "No pending requests". - Removed the now-unused onSign/onReject prop plumbing; the no-op handlers in WalletScreen + the dead PendingCosigns import in NodesScreen are deleted alongside. Mock card data is intentionally retained so the panel still communicates the intended UX of multisig once it's wired.
The wallet's recent-activity panel previously rendered both empty and
error states as a single dim TILE_LABEL line ("NO ACTIVITY YET" or
the raw error string). Demo posture is much better with explicit copy
and a recovery affordance.
Empty state:
- Title: "No transactions yet" (sentence case, friendlier than the
shouty TILE_LABEL).
- Subline: "Tap Receive above to share your address." Routes the
user toward the next obvious step instead of leaving them parked.
- Bird mascot retained.
Error state:
- Title: "Couldn't load activity"
- Subline shows the raw error message (preserved for diagnostics).
- New "Try again" button that calls onRefresh — same handler the
pull-to-refresh control uses, no new wiring.
- Wifi-off icon to make the failure mode read at a glance.
Also:
- useWalletBalance: catch-all error string upgraded from "Failed to
fetch balance" → "Couldn't refresh balance — pull to retry" so
the message tells the user how to recover, not just that it broke.
The eye-toggle and refresh-balance affordances on the wallet's balance tile previously had: - 13-14pt icons in colors.textTertiary (hard to see at a glance) - hitSlop 8pt → ~30pt diameter touch target (under both Apple's 44pt and Material's 48dp guidance) - No accessibilityLabel / role / state — VoiceOver and TalkBack would read both as a generic unlabelled control This pass keeps the icons in their existing layout slot (avoids colliding with header redesign work in flight) and just makes them properly tappable and announced: - Bump icon size 13/14 → 18 and color textTertiary → textSecondary for visibility against the surface2 tile background - Bump hitSlop 8 → 16 (target ≈50pt diameter) - Add accessibilityRole "button", a purpose-driven accessibilityLabel for each, and accessibilityState.checked for the eye toggle so screen readers announce the balance-hidden state - Disable the refresh button while loading and show pressed-opacity feedback so the tap registers visibly even before the network round-trip - Bump the icon-row gap 12 → 16 so the larger hit zones don't visually crowd the spinner
…imitive gesture/dim bugs (#41) * refactor(tx-detail): migrate TxDetailModal to AppBottomSheet The transaction-detail modal previously used the bespoke RN Modal + dismissArea pattern that predates the AppBottomSheet primitive landed in #34. The recipe in BottomSheet.tsx's docstring lists this surface as a planned consumer; this PR makes the swap. Behavior changes for users: - Pull-to-dismiss gesture (1:1 finger tracking on the UI thread) with velocity-based release. - Backdrop dim now fades smoothly with drag progress instead of the binary slide-in/out of the previous implementation. - Apple-modal-curve open/close timing. The modal's contents are unchanged: the same header with status pill, the same six DetailRow entries, the same Explorer + Close action pair. Existing inner ScrollView is dropped; AppBottomSheet's maxHeight 90% is sufficient for the detail panel's natural height across all real-world variants (memo + mint extras included). This is the second consumer of AppBottomSheet after TokenPicker, strengthening the primitive as the single source of truth for sheet-style surfaces. * fix(bottom-sheet): leading backdrop fade + more sensitive pan activation Two bugs uncovered during Seeker smoke of the TxDetailModal migration: 1. Backdrop "slides up" with the sheet on open. Root cause: backdrop opacity was interpolated directly from `translateY`, so the dim cross-faded synchronously with the slide. Visually that reads as "dim rising with the sheet" rather than the iOS-standard "sheet rises onto already-dim background". Fix: give the backdrop its own `backdropOpacity` shared value. On open, animate it over 200ms with no delay (leading the 320ms sheet slide). On close, animate both in lockstep over 220ms. During interactive drag, the Pan's onUpdate mirrors translateY to backdropOpacity so dismissal still feels coupled to the gesture. 2. Pan-from-body unresponsive. Root cause: `activeOffsetY(12)` is too permissive a window for Pressable children to compete in via RN's responder system. Press claims the touch, drags through the first ~10pt before Pressable cancels (movement > pressRectOffset), and by then gesture-handler often can't pick up the in-progress drag cleanly. No `activeOffsetX` constraint also meant diagonal flicks could ambiguously route between Pan and child taps. Fix: lower `activeOffsetY` 12 → 5 (claim before Pressable's cancel window), add `activeOffsetX([-20, 20])` to constrain to vertical motion, and add `shouldCancelWhenOutside(false)` so a drag that crosses the sheet bounds during interaction doesn't get cancelled. Affects every consumer of AppBottomSheet: TokenPicker (already on upstream/v3 from PR #34) and TxDetailModal (new in this PR). No behavior changes for callers — same props, same lifecycle. * fix(bottom-sheet): kill snap-back jitter — timing curve instead of spring Snap-back after a partial drag previously used a spring (damping 22, stiffness 320). That spring is mathematically underdamped — damping ratio ≈ 0.61 against critical 35.78 — so the system overshoots the rest position and oscillates on settle. After short drags (e.g. user pulls down 20pt then releases without dismissing) there's no perceptual cover for the wobble, and it reads as jitter. Replace with a deterministic timing curve (260ms ease-out exponential) for both translateY and backdropOpacity. No overshoot, no oscillation, no visible jitter on settle. This is what production sheet libraries (gorhom/bottom-sheet, etc.) use for snap-back for exactly this reason. Affects every consumer of AppBottomSheet — TokenPicker and TxDetailModal (this PR). Same props, same lifecycle.
* fix(beacon): strip fake yield/rep numbers and disable biometric CTAs The Beacon Registry surface previously rendered a polished "Become a Beacon → Sign with Biometrics" flow whose onPress just flipped the LXMF beacon-mode flag — no biometric prompt, no signature, no on-chain stake. The active state then displayed hardcoded yields, rep scores, JitoSOL conversions, and a "0.000312 SOL EARNED" hero that was a literal constant. The Stake modal repeated the lie: "Sign with Biometrics" → onPress=dismissStake. This kills the theatre while keeping the roadmap signal: - Strip JITO_RATE / JITO_APY / earned / cosigns constants and the derived jitoAmt / yieldAmt / repScore / newRep / newYield values. - Active hero now shows reachable-node count (real, derived from beacons + online peers) under a NODES REACHABLE label, plus an inline preview notice explaining staking and reputation are not yet wired. - Become-a-Beacon modal: CTA now reads "Preview — not yet active" with pointerEvents disabled and reduced opacity. Lock icon removed. Beacon mode still auto-activates via the existing hasInternet effect, so no functional regression. - Stake modal: title gets a PREVIEW badge; impact rows replaced with a copy block explaining stake delegation is not yet wired; CTA neutered identically to Become-a-Beacon. Mock card data and the visual stepper are intentionally retained as preview chrome so the planned UX still reads. * revise(beacon): preserve roadmap chrome, only neuter modal CTAs Walking back the aggressive strip from the previous commit. The JITO_RATE / APY / REP SCORE / SOL EARNED hero is intentional roadmap signaling from the team lead — the visual chrome communicates "this is what beacon staking will look like" and that signal has product value, not a credibility cost. What restores: - Active hero: SOL EARNED, REP SCORE, JITOSOL, SOL/YR row, co-signs + reachable footer all return as designed. - JITO_RATE / JITO_APY / cosigns / earned constants restored. - "Stake More SOL" modal title restored (no PREVIEW badge needed at this level). - Stake stepper impact rows restored (rep / yield before-after). What stays neutered: - Become-a-Beacon modal CTA: replaces lock-icon + "Sign with Biometrics" with a disabled "Preview — not yet active" pill. Beacon mode still auto-activates via the existing hasInternet effect, so the modal can still demo the staking UX without triggering a fake biometric handler. - Stake modal CTA: same neutering. The single credibility risk we still address is the lock-icon-with- fake-onPress that judges would tap and find unwired. Everything else is preserved. Net diff vs upstream/v3: 12+ / 12-.
* feat(ui): add PigeonLoader + dev preview route
Phantom-style full-screen loading overlay for transactional flows.
Uses animated WebP via the already-installed expo-image (no new deps).
- assets/animations/sending.webp — 240x240 q70 animated WebP, ~350KB
- components/ui/PigeonLoader.tsx — Modal overlay with optional cancel
- app/dev/pigeon-loader.tsx — preview route for testing without a real tx
Reachable for QA via: anonmesh://dev/pigeon-loader
Component is unwired; send-flow integration will follow in a separate PR
to keep this lane-clean against components/send/.
* fix(ui): PigeonLoader — kill WebP ghosting + bump render size
Device review showed two issues:
1. Ghost frames stuck on screen during loop. Root cause: ffmpeg's webp
encoder writes frames with dispose=none + blend=alpha, so each frame
composites onto the previous. Sparse transparent regions left
trailing pixels.
Fix: pre-composite every source frame onto a solid #00080c (theme
background) via Pillow, then save without alpha. No alpha plane =
every frame is fully opaque = ghosting is impossible.
Side effect: 358KB -> 292KB (smaller, no alpha plane).
2. Loader felt too small. Image render 160 -> 220 (≈37% bigger).
Source resolution bumped 240 -> 280 to keep sharpness at the
larger render size on 3x devices.
3. Backdrop opacity dropped from 92% to fully opaque, matching the
Phantom-wallet pattern for blocking loaders. Also avoids the
visible-rectangle artifact that would appear if a fully-opaque
sprite sat on a translucent backdrop of a slightly different
blended color.
* feat(ui): PigeonLoader — mesh bg, pulse, haptics, success state
Elevate the send loader from a generic spinner into a brand moment that
tells the AnonMesh routing story while a tx is in flight.
Changes:
- Bigger hero — pigeon render 220 -> 280pt (fills the central area)
- Larger label (24pt semibold), sublabel hidden by default — less text
- Subtle pulse on the pigeon (1.0 ↔ 1.03 / 1.8s sine) so the image
doesn't sit still during the WebP's quieter frames
- Ambient mesh background — 12 drifting cyan nodes + 19 connecting
lines on the brand primary color, driven by a single shared phase
via Reanimated worklets. Renders behind the pigeon at low opacity.
Tells the "your tx is hopping across peers" story visually.
- New success state (`status: 'loading' | 'success'`):
pigeon crossfades out, Feather check springs in with a brand-green
glow ring. Fires the success notification haptic.
- Mount haptic — medium impact when the loader becomes visible,
announcing the blocking state.
Asset:
- assets/animations/sending.webp re-encoded with alpha so the mesh BG
reads through the transparent regions around the pigeon. Avoids
ghosting via explicit per-frame disposal=2 (background) in the
WebP encode (Pillow), so each frame fully replaces the previous.
320x320 q=70 alpha_q=80 → 614KB.
Dev route:
- "Full lifecycle" buttons play sending → success → dismiss so the
whole sequence can be felt without a real tx
- Manual status toggle exposes the loading↔success transition
- Sublabel and cancel button toggles still present
Deps: zero new packages. Uses expo-image, expo-haptics,
react-native-reanimated, react-native-svg — all already installed.
* refactor(ui): PigeonLoader — logo-derived mesh + SVG success ring
Two device-review fixes:
1. Mesh BG redrawn from the actual AnonMesh logo geometry.
- Node positions extracted from assets/icons/anonmesh_white_icon.png
via Pillow distance-transform (6 nodes, 10 edges).
- Logo scaled 1.15× the screen, centered. The pigeon mounts in
front of the central node, exactly like the brand icon's core.
- Static dots dropped in favor of stable topology that matches the
logo silhouette.
- One bright "message" dot per edge, all driven by a single
11-second shared phase. Each message travels half the cycle
(visible) then fades out for the other half. Phases staggered
so messages don't all arrive simultaneously.
- Color cohered to brand primary cyan at three opacity tiers:
edges 15%, nodes 20%, traveling messages 85%.
2. Success ring rebuilt in SVG to kill the octagon artifact.
The previous View used borderRadius=48 + borderWidth=2.5 +
elevation=10. On Android, large-radius rounded views with
elevation shadows render with quantized polygon edges — that
was the "incomplete shape inside the circle" symptom.
Now: <Svg> with three concentric Circles (outer halo, mid halo,
ring stroke) for guaranteed anti-aliased curvature and a soft
glow that doesn't rely on platform shadow clipping.
* perf(ui): MeshBackground — align center node, GPU-composite messages
Three improvements rolled together; the perf one matters most.
1. Center node aligned to screen center.
The logo's central node sits at (0.596, 0.417) in source coords, not
(0.5, 0.5). Previously the bounding box was centered, which made the
visual center sit right-of-center on screen. New positioning anchors
that single node to (width/2, height/2). The pigeon now flies over
the logo's actual core, and the whole pattern reads as the brand mark
instead of a generic web.
2. Scaled 1.15× → 1.45×.
Outer nodes intentionally drift past the screen edges. Pattern reads
as ambient / abstract rather than "here is a small diagram." Static
node/edge opacities lowered to compensate (14% / 12%) so the mesh
stays subordinate to the pigeon.
3. Animated messages moved off react-native-svg onto Animated.View
transforms. SVG <Circle> with useAnimatedProps was triggering native
tree updates each frame across 10 elements — that's where the perf
cost was hiding. Each message is now a positioned Animated.View
driven by useAnimatedStyle, so the transform updates run UI-thread
only and never round-trip to RN's view manager.
Side benefits from the rewrite:
- Smoothstep easing on the position so dots accelerate out of one
node and decelerate into the next. Reads as more deliberate motion.
- Three-stage opacity curve: fade in over 18% of the visible window,
hold at peak, fade out over the last 18%. No more abrupt blink-on
/ blink-off at edge endpoints.
- Stacked core + halo (one bright inner, one softer outer) for a
glow without relying on platform shadow primitives.
- Visible window dropped to 42% per cycle, travel period 11s → 7s.
Net result: fewer dots on screen at once but each one is brighter
and faster, which reads as more pronounced motion without crowding.
Static layer (nodes + edges) still SVG — drawn once, zero per-frame
cost.
* revert(ui): drop MeshBackground from PigeonLoader
Mesh BG didn't add enough to justify the visual + perf cost on top of
the pigeon. Loader is now just the dark backdrop, pigeon, and label.
Cleaner base to layer alternative polish on.
* feat(ui): PigeonLoader — spotlight, label slide, success shockwave
Three polish layers replacing the dropped mesh BG. None of them add
motion that competes with the pigeon during normal loading.
1. Radial spotlight backdrop.
Static SVG <RadialGradient> centered on screen — cyan primary at
~11% opacity in the core, fading to transparent at 100%. The void
backdrop now reads as an intentional stage rather than dead black.
Zero motion, single-render, no per-frame cost.
2. SlideLabel — animated label transitions.
When the label text changes (Sending → Sent on success, or any
other override), the outgoing text eases up and fades out while the
new text rises up and fades in. State change feels announced
instead of just swapped. The transition is keyed on text equality
so identical re-renders don't trigger it.
3. Success shockwave.
Single ring positioned at screen center, hidden until the success
transition. Fires alongside the check spring with the same 80ms
delay so the ring reads as the wave's anchor. Scales 0.85 → 3.6
over 720ms with opacity 0.55 → 0, easing out cubic. The wave moves
off-screen by the end of the animation, which is the intended
"exclamation point" feel for the success moment.
Reset on status reversal (success → loading) snaps all three success
animations to their idle state, so the dev route's manual toggle stays
clean across rapid changes.
* feat(send): wire PigeonLoader into ReviewCard submit flow
Loader now appears for real sends. Path:
1. Slider hit -> setIsConfirming(true), setTxStatus('loading')
PigeonLoader Modal fades in (built-in animationType="fade"), pigeon
pulses, mount haptic fires.
2. await sendSolTransfer/sendSplTransfer -> result.signature
3. saveAddressBookRecipient(to) (in the same try block)
4. setTxStatus('success') -> loader crossfades to check ring,
shockwave + confirm haptic, label slides Sending -> Sent.
5. setTimeout 1200ms -> router.replace('/send/success', ...). The
1.2s hold lets the success state actually register before nav.
6. ReviewCard unmounts, loader Modal closes alongside, Success
screen with explorer link is now visible.
Error path: catch -> setTxStatus('loading'), setIsConfirming(false),
slider resets. Loader fades out, error panel visible on Review for
retry. Drops the previous `finally { setIsConfirming(false) }` since
the success path now owns its own dismissal via navigation.
Lane note: QVAC's epic/qvac-integration branch also edits
ReviewCard.tsx (memo prop plumbing). The diffs do not overlap line-
for-line — my edits are around state declarations and try/catch
control flow; theirs are around prop signature, fee estimate args,
and adding a Memo DetailRow. 3-way merge should clear without
manual intervention, but whoever lands second should verify.
* refactor(send): collapse loader-success into SuccessCard
Standard wallet pattern: the loader does loading, the success screen
does the success moment. One haptic, one check, no contrived hold.
Bug fix:
Previously the loader fired haptics.confirm() in its success state,
held 1.2s, navigated. Then SuccessCard fired haptics.confirm() on
mount. Two confirm haptics 1.2s apart on every send. Two check
rings (animated then static). Removed by:
- dropping setTxStatus('success') + setTimeout from ReviewCard;
we navigate immediately when sendSolTransfer/sendSplTransfer
returns the signature
- SuccessCard now owns the celebration: SVG check ring + shockwave
spring in on mount, single confirm haptic, lifted from the
PigeonLoader work (same dimensions, same easing)
Other:
- SuccessCard title "transfer in motion" → "sent" (present tense
on a screen the user lands on AFTER submit)
- Loader's status prop kept in PigeonLoader API for future non-send
consumers (refresh/connect/mesh-ack flows), just unused here
* fix(send): honest pill copy on SuccessCard
"Submitted to devnet" → "Awaiting confirmation".
The screen mounts on signature receipt, but the tx isn't yet confirmed
on chain (no polling on this code path). The previous copy claimed a
status we hadn't verified. The new copy is what's actually true at
that moment. Subtitle already acknowledges the lag.
Proper fix (status polling + failure-state route) lives in a follow-up
PR; this is the cheap honesty change.
* fix(a11y): PigeonLoader — move alert role off backdrop so Cancel is focusable
`accessible` on the backdrop View grouped all its children into a single
accessibility focus target, which meant the optional Cancel Pressable
wasn't independently reachable by TalkBack/VoiceOver. Real bug surfaced
during PR review.
Fix: keep the alert/live-region semantics, but anchor them on the content
stack (pigeon sprite + label + sublabel) rather than the full backdrop.
The Cancel Pressable now sits as a sibling of the content View at the
backdrop level, so it's a separate accessibility element.
Behavior unchanged for sighted users — same announcement when the loader
mounts, same dismiss handling. Screen reader users can now actually tab
to Cancel.
The related review comment about `onRequestClose` being a no-op when
onCancel is undefined is intentional and being deferred — blocking back-
button during a tx submit matches Phantom/Cash App/Apple Pay UX, and the
abort signal in the follow-up confirmation PR bounds the worst-case
"trap" duration via the confirmation timeout.
* feat(ui): add PigeonLoader + dev preview route
Phantom-style full-screen loading overlay for transactional flows.
Uses animated WebP via the already-installed expo-image (no new deps).
- assets/animations/sending.webp — 240x240 q70 animated WebP, ~350KB
- components/ui/PigeonLoader.tsx — Modal overlay with optional cancel
- app/dev/pigeon-loader.tsx — preview route for testing without a real tx
Reachable for QA via: anonmesh://dev/pigeon-loader
Component is unwired; send-flow integration will follow in a separate PR
to keep this lane-clean against components/send/.
* fix(ui): PigeonLoader — kill WebP ghosting + bump render size
Device review showed two issues:
1. Ghost frames stuck on screen during loop. Root cause: ffmpeg's webp
encoder writes frames with dispose=none + blend=alpha, so each frame
composites onto the previous. Sparse transparent regions left
trailing pixels.
Fix: pre-composite every source frame onto a solid #00080c (theme
background) via Pillow, then save without alpha. No alpha plane =
every frame is fully opaque = ghosting is impossible.
Side effect: 358KB -> 292KB (smaller, no alpha plane).
2. Loader felt too small. Image render 160 -> 220 (≈37% bigger).
Source resolution bumped 240 -> 280 to keep sharpness at the
larger render size on 3x devices.
3. Backdrop opacity dropped from 92% to fully opaque, matching the
Phantom-wallet pattern for blocking loaders. Also avoids the
visible-rectangle artifact that would appear if a fully-opaque
sprite sat on a translucent backdrop of a slightly different
blended color.
* feat(ui): PigeonLoader — mesh bg, pulse, haptics, success state
Elevate the send loader from a generic spinner into a brand moment that
tells the AnonMesh routing story while a tx is in flight.
Changes:
- Bigger hero — pigeon render 220 -> 280pt (fills the central area)
- Larger label (24pt semibold), sublabel hidden by default — less text
- Subtle pulse on the pigeon (1.0 ↔ 1.03 / 1.8s sine) so the image
doesn't sit still during the WebP's quieter frames
- Ambient mesh background — 12 drifting cyan nodes + 19 connecting
lines on the brand primary color, driven by a single shared phase
via Reanimated worklets. Renders behind the pigeon at low opacity.
Tells the "your tx is hopping across peers" story visually.
- New success state (`status: 'loading' | 'success'`):
pigeon crossfades out, Feather check springs in with a brand-green
glow ring. Fires the success notification haptic.
- Mount haptic — medium impact when the loader becomes visible,
announcing the blocking state.
Asset:
- assets/animations/sending.webp re-encoded with alpha so the mesh BG
reads through the transparent regions around the pigeon. Avoids
ghosting via explicit per-frame disposal=2 (background) in the
WebP encode (Pillow), so each frame fully replaces the previous.
320x320 q=70 alpha_q=80 → 614KB.
Dev route:
- "Full lifecycle" buttons play sending → success → dismiss so the
whole sequence can be felt without a real tx
- Manual status toggle exposes the loading↔success transition
- Sublabel and cancel button toggles still present
Deps: zero new packages. Uses expo-image, expo-haptics,
react-native-reanimated, react-native-svg — all already installed.
* refactor(ui): PigeonLoader — logo-derived mesh + SVG success ring
Two device-review fixes:
1. Mesh BG redrawn from the actual AnonMesh logo geometry.
- Node positions extracted from assets/icons/anonmesh_white_icon.png
via Pillow distance-transform (6 nodes, 10 edges).
- Logo scaled 1.15× the screen, centered. The pigeon mounts in
front of the central node, exactly like the brand icon's core.
- Static dots dropped in favor of stable topology that matches the
logo silhouette.
- One bright "message" dot per edge, all driven by a single
11-second shared phase. Each message travels half the cycle
(visible) then fades out for the other half. Phases staggered
so messages don't all arrive simultaneously.
- Color cohered to brand primary cyan at three opacity tiers:
edges 15%, nodes 20%, traveling messages 85%.
2. Success ring rebuilt in SVG to kill the octagon artifact.
The previous View used borderRadius=48 + borderWidth=2.5 +
elevation=10. On Android, large-radius rounded views with
elevation shadows render with quantized polygon edges — that
was the "incomplete shape inside the circle" symptom.
Now: <Svg> with three concentric Circles (outer halo, mid halo,
ring stroke) for guaranteed anti-aliased curvature and a soft
glow that doesn't rely on platform shadow clipping.
* perf(ui): MeshBackground — align center node, GPU-composite messages
Three improvements rolled together; the perf one matters most.
1. Center node aligned to screen center.
The logo's central node sits at (0.596, 0.417) in source coords, not
(0.5, 0.5). Previously the bounding box was centered, which made the
visual center sit right-of-center on screen. New positioning anchors
that single node to (width/2, height/2). The pigeon now flies over
the logo's actual core, and the whole pattern reads as the brand mark
instead of a generic web.
2. Scaled 1.15× → 1.45×.
Outer nodes intentionally drift past the screen edges. Pattern reads
as ambient / abstract rather than "here is a small diagram." Static
node/edge opacities lowered to compensate (14% / 12%) so the mesh
stays subordinate to the pigeon.
3. Animated messages moved off react-native-svg onto Animated.View
transforms. SVG <Circle> with useAnimatedProps was triggering native
tree updates each frame across 10 elements — that's where the perf
cost was hiding. Each message is now a positioned Animated.View
driven by useAnimatedStyle, so the transform updates run UI-thread
only and never round-trip to RN's view manager.
Side benefits from the rewrite:
- Smoothstep easing on the position so dots accelerate out of one
node and decelerate into the next. Reads as more deliberate motion.
- Three-stage opacity curve: fade in over 18% of the visible window,
hold at peak, fade out over the last 18%. No more abrupt blink-on
/ blink-off at edge endpoints.
- Stacked core + halo (one bright inner, one softer outer) for a
glow without relying on platform shadow primitives.
- Visible window dropped to 42% per cycle, travel period 11s → 7s.
Net result: fewer dots on screen at once but each one is brighter
and faster, which reads as more pronounced motion without crowding.
Static layer (nodes + edges) still SVG — drawn once, zero per-frame
cost.
* revert(ui): drop MeshBackground from PigeonLoader
Mesh BG didn't add enough to justify the visual + perf cost on top of
the pigeon. Loader is now just the dark backdrop, pigeon, and label.
Cleaner base to layer alternative polish on.
* feat(ui): PigeonLoader — spotlight, label slide, success shockwave
Three polish layers replacing the dropped mesh BG. None of them add
motion that competes with the pigeon during normal loading.
1. Radial spotlight backdrop.
Static SVG <RadialGradient> centered on screen — cyan primary at
~11% opacity in the core, fading to transparent at 100%. The void
backdrop now reads as an intentional stage rather than dead black.
Zero motion, single-render, no per-frame cost.
2. SlideLabel — animated label transitions.
When the label text changes (Sending → Sent on success, or any
other override), the outgoing text eases up and fades out while the
new text rises up and fades in. State change feels announced
instead of just swapped. The transition is keyed on text equality
so identical re-renders don't trigger it.
3. Success shockwave.
Single ring positioned at screen center, hidden until the success
transition. Fires alongside the check spring with the same 80ms
delay so the ring reads as the wave's anchor. Scales 0.85 → 3.6
over 720ms with opacity 0.55 → 0, easing out cubic. The wave moves
off-screen by the end of the animation, which is the intended
"exclamation point" feel for the success moment.
Reset on status reversal (success → loading) snaps all three success
animations to their idle state, so the dev route's manual toggle stays
clean across rapid changes.
* feat(send): wire PigeonLoader into ReviewCard submit flow
Loader now appears for real sends. Path:
1. Slider hit -> setIsConfirming(true), setTxStatus('loading')
PigeonLoader Modal fades in (built-in animationType="fade"), pigeon
pulses, mount haptic fires.
2. await sendSolTransfer/sendSplTransfer -> result.signature
3. saveAddressBookRecipient(to) (in the same try block)
4. setTxStatus('success') -> loader crossfades to check ring,
shockwave + confirm haptic, label slides Sending -> Sent.
5. setTimeout 1200ms -> router.replace('/send/success', ...). The
1.2s hold lets the success state actually register before nav.
6. ReviewCard unmounts, loader Modal closes alongside, Success
screen with explorer link is now visible.
Error path: catch -> setTxStatus('loading'), setIsConfirming(false),
slider resets. Loader fades out, error panel visible on Review for
retry. Drops the previous `finally { setIsConfirming(false) }` since
the success path now owns its own dismissal via navigation.
Lane note: QVAC's epic/qvac-integration branch also edits
ReviewCard.tsx (memo prop plumbing). The diffs do not overlap line-
for-line — my edits are around state declarations and try/catch
control flow; theirs are around prop signature, fee estimate args,
and adding a Memo DetailRow. 3-way merge should clear without
manual intervention, but whoever lands second should verify.
* refactor(send): collapse loader-success into SuccessCard
Standard wallet pattern: the loader does loading, the success screen
does the success moment. One haptic, one check, no contrived hold.
Bug fix:
Previously the loader fired haptics.confirm() in its success state,
held 1.2s, navigated. Then SuccessCard fired haptics.confirm() on
mount. Two confirm haptics 1.2s apart on every send. Two check
rings (animated then static). Removed by:
- dropping setTxStatus('success') + setTimeout from ReviewCard;
we navigate immediately when sendSolTransfer/sendSplTransfer
returns the signature
- SuccessCard now owns the celebration: SVG check ring + shockwave
spring in on mount, single confirm haptic, lifted from the
PigeonLoader work (same dimensions, same easing)
Other:
- SuccessCard title "transfer in motion" → "sent" (present tense
on a screen the user lands on AFTER submit)
- Loader's status prop kept in PigeonLoader API for future non-send
consumers (refresh/connect/mesh-ack flows), just unused here
* fix(send): honest pill copy on SuccessCard
"Submitted to devnet" → "Awaiting confirmation".
The screen mounts on signature receipt, but the tx isn't yet confirmed
on chain (no polling on this code path). The previous copy claimed a
status we hadn't verified. The new copy is what's actually true at
that moment. Subtitle already acknowledges the lag.
Proper fix (status polling + failure-state route) lives in a follow-up
PR; this is the cheap honesty change.
* feat(network): add getSignatureStatus + confirmTransaction polling
Extend IRpcAdapter with getSignatureStatus(signature) so callers can
poll for on-chain confirmation in a transport-blind way. Direct and
mesh implementations both delegate to their existing infrastructure:
- DirectRpcAdapter.getSignatureStatus: connection.getSignatureStatus
with searchTransactionHistory=false (recent submission only)
- MeshRpcAdapter.getSignatureStatus: routes through the same LXMF
relay path as the existing RPC calls, using getSignatureStatuses
with a single-element array (the only multi-sig form web3.js's
JSON-RPC exposes)
- IsolatedRpcAdapter: throws "No Solana route available" matching
the existing pattern
Adds confirmTransaction(rpcAdapter, signature, { signal? }) in
sendTransaction.ts:
- Polls every 500ms online / 1000ms mesh
- Overall budget 60s online / 120s mesh (per-call mesh timeout is
30s — need headroom for ≥3 stuck calls)
- Per-call rejections are caught and logged inside the loop so a
single hung getSignatureStatus doesn't abort confirmation
- Returns ConfirmResult discriminated union: 'confirmed' | 'failed'
(reason: 'on-chain' | 'timeout') | 'cancelled' for AbortSignal use
- Cancellation triggers an early sleep-exit so unmount doesn't have
to wait out a full pollInterval before returning
No call sites wired yet. ReviewCard rewire + UI states land in
follow-up commits on this branch.
* feat(send): error translation map for FailureCard copy
src/services/sendErrorMessages.ts (pure, no React).
describeSendFailure(result, mode) maps a ConfirmResult failure into:
{ subtitle, pillLabel }
Pattern-matches common Solana errors against a combined haystack of:
- summarizeError's extracted view (message, name, raw, cause)
- JSON.stringify of the raw err for object-form TransactionError keys
like { InstructionError: [...] }
Covered cases: blockhash expired, insufficient funds, missing account,
program error, generic on-chain failure, network timeout (mode-aware:
"Mesh timeout" vs "Network timeout"). Anything unmatched falls through
to a generic message that directs the user to the raw error toggle.
formatRawError(err) produces the multi-line monospace block for the
FailureCard's collapsible "View error details" section.
* feat(send): FailureCard + /send/failure route
Mirrors SuccessCard structure with the same SVG ring spring + shockwave
pattern; tone shifts to colors.error and the inner icon is an X drawn
as two SVG lines instead of a Feather check.
Component (FailureCard.tsx):
- haptics.warning() on mount (not confirm — different signal)
- Title "couldn't send" (lowercase, brand-consistent)
- Subtitle + pillLabel arrive as props from ReviewCard, computed via
describeSendFailure() so the failure copy is captured at the moment
of failure (not recomputed later in case mode changes)
- Amount tile so the user sees what failed
- Glass card with status pill + signature (when present; even failed
txs land on chain and have copyable explorer-linkable signatures)
- "View error details" collapsible toggle revealing rawError in
monospace with a Copy button
- Footer: Done (secondary, back to wallet) + Try again (primary,
router.back to Review with state intact)
- View on explorer button below the error detail when a signature
exists (failed txs are still inspectable)
Route (app/send/failure.tsx):
- Thin wrapper, reads params via useLocalSearchParams
- Inline <Stack.Screen options={{ gestureEnabled: false }} /> to
prevent swipe-back to a phantom Review state (no _layout.tsx edit
needed — same pattern as app/dev/pigeon-loader.tsx)
- asString() helper to defend against query-string arrays from
Expo Router when params are bracketed
Not wired into ReviewCard yet — that's the next commit.
* feat(send): real on-chain confirmation in ReviewCard handleConfirm
Send flow becomes two-phase:
1. Submit (existing sendSolTransfer / sendSplTransfer call) → signature
2. Confirm (new): poll getSignatureStatus via confirmTransaction until
the chain reports 'confirmed' or 'finalized', errors out, or the
budget elapses
State:
- txPhase: 'submitting' | 'confirming' | null drives loader sublabel
- confirmAbortRef: AbortController for cancellation on unmount
PigeonLoader sublabel updates as the phase advances. The loader stays
visible through both phases so users see the actual work happening.
Sublabels:
'submitting' → "Submitting"
'confirming' → "Confirming on devnet"
Outcome routing:
- 'confirmed' → router.replace('/send/success', { txId, ... })
- 'failed' → router.replace('/send/failure', { subtitle, pillLabel,
rawError, txId, ... }) with copy computed by
describeSendFailure() at the moment of failure
- 'cancelled' → return silently. Caller already unmounted.
The unmount useEffect aborts any in-flight confirmation. Combined with
the 'cancelled' result branch, this prevents two classes of bugs:
- ghost router.replace on a phantom ReviewCard after user back-swipes
- orphan polling loops that nobody is listening to
Existing submission-stage error path is unchanged — wallet rejection,
RPC unreachable, validation failures still surface inline on Review.
* chore(send): SuccessCard reflects real confirmation
Phase 1 SuccessCard mounted on signature receipt with an "Awaiting
confirmation" pill — honest about not having verified the chain.
Phase 2 confirms before navigation, so the copy can land:
- Subtitle: "Settlement confirmed on devnet." (no more lag caveat
because we've already waited for the lag)
- Pill: "Confirmed" / green tone (no more "Awaiting" / cyan)
Behavior unchanged otherwise: check ring spring + shockwave + single
confirm haptic on mount.
* fix(a11y): PigeonLoader — same accessibility fix as PR #44 (carry-through)
Stack carries PR #44's PigeonLoader through this branch, so the
accessibility fix needs to land here too. Mechanical mirror of the
fix on epic/pigeon-loader-component:
`accessible` + alert role moved from the backdrop View to the content
stack so the optional Cancel Pressable remains independently focusable
for screen readers.
* fix(send): three review-driven fixes — abort listener leak, push-not-replace, timeout retry guard
1. AbortSignal listener leak in confirmTransaction's per-poll sleep.
Each iteration's `signal.addEventListener('abort', onAbort, { once: true })`
was never removed when the timer fired normally — only when abort
itself fired. Across a 60s budget at 500ms cadence, that's up to 120
dead listeners on the same controller before unmount. Now the timer
handler explicitly removeEventListener's the onAbort, so listeners
are bounded to one live entry per cycle.
2. router.replace → router.push for the failure path.
With replace, /send/review got swapped out of the stack before
navigation to /send/failure. router.back() from FailureCard landed
on /send/amount instead of /send/review, losing the form state.
With push, /send/review stays on the stack, "Try again" returns
the user exactly where they were with amount/recipient intact.
Success path stays as replace (terminal state, going back is
meaningless).
3. Timeout-failure double-send guard.
reason='timeout' means the tx submitted and we stopped watching
before getting a terminal status. On online mode, the blockhash
may still be live for up to 30s after the 60s budget — meaning
the tx COULD still confirm. If the user taps "Try again" in that
window, they double-send. New plumbing:
- ReviewCard passes `reason` as a route param
- app/send/failure.tsx reads it and computes `isTimeout`
- FailureCard hides "Try again" entirely on timeout, swaps Done
to primary, and bumps "View on explorer" from secondary to
primary so it reads as the recommended action
User intending to retry on a true timeout just taps Done → opens
wallet → re-enters Send. One extra tap to avoid a real money risk.
Android MWA throws java.util.concurrent.CancellationException when the user dismisses the Phantom/Solflare sheet before signing. The existing walletDenial substring matcher missed it (had "cancelled"/"canceled" but not "cancellation"), so a user-cancel surfaced the raw exception string inline and logged ERROR-level. Add "cancellation" to the fragment list and demote the catch-block log to console.warn when the error is classified as user-cancel.
* chore(meta): bump expo.version to 1.0.2 * chore(settings): source app version from expo-constants Drops the hardcoded "0.4.1 · build 2026.04" string in favor of Constants.expoConfig?.version so app.json stays the single source of truth. Also bumps package.json version to match app.json (1.0.2). * feat(primitives): add PreviewBadge component Canonical amber PREVIEW pill for roadmap placeholders, matching the shape pendingCosigns + BeaconRegistry CTAs already use. No consumers in this commit — consumer migrations land in a follow-up cluster so each migration is reviewable on its own. * feat(primitives): add PreviewedActions wrapper Wraps roadmap-preview CTAs so they look real but can't fire — pointerEvents:none shield + 0.5 opacity + transient 'not yet active' hint on tap. Light haptic on press. Pairs with PreviewBadge; consumer migrations land in a follow-up cluster. * chore(meta): add bug + feature + privacy issue templates Per ROADMAP § 1.4 — first-class bug intake channel was never shipped. Three structured templates: bug (device, OS, network state, repro), feature (problem framing, scope), privacy (claim, evidence, severity). * docs(readme): correct AES-128 + qualify centralized-server + relay claims Aligns README with what the code actually ships per AUDIT § 1 R-01..R-27. - AES-256 → AES-128 + HMAC per Reticulum spec (the actual on-the-wire crypto) - "No servers" → "No centralized servers" + note community TCP relays - "routes through nothing" → peer-to-peer first, beacon-relays as fallback - Solana section: name "confidential offline transfers" as coming-soon, note mesh-RPC payload is proxied (not yet encrypted to relay) Two FALSE claims + three MISLEADING ones resolved. Remaining drift items tracked in AUDIT for follow-up. * chore(ci): add honesty-check workflow Greps the diff for banned theatre phrases (ARCIUM, MPC 3/3, Confidential Offline, JITO_RATE, etc.) so they can't sneak back into mobile_app/ or README.md without a PreviewBadge / coming-soon wrapper. Runs on PRs + pushes to v3. Per ROADMAP § 0.8. * chore(meta): sync package-lock to 1.0.2
* refactor(wallet): move dead Send/Swap/Yield panels out of barrel These four files (WalletTabs, SendPanel, SwapPanel, YieldPanel) are roadmap-preview surfaces with no live behavior — grep confirms zero consumers outside the barrel. The barrel re-export keeps them one casual import away from showing up in the wallet bento with theatre copy (MPC 3/3, JITO_RATE, dead Deposit/Withdraw). Removes the four exports from components/wallet/index.ts and adds a FUTURE header comment to each panel pointing back to AUDIT A6 / ROADMAP § 0.A.8. Files stay so the design isn't lost — re-export when each is wired and wrapped in <PreviewBadge>. * refactor(network): extract solanaConnection singleton Connection construction lived inside sendTransaction.ts, which made the singleton's existence a side-effect of importing the send service. Three hooks already reached past it. Pulls the RPC_URL resolution + new Connection into src/infrastructure/network/connection.ts; sendTransaction.ts now imports and re-exports it so existing callers don't churn. useNetworkMode + useWalletBalance read from the new module directly. ARCH P2 #1. * refactor: consolidate sliceNewEvents to single util The 'detect newly-prepended LXMF events given prev head + prev count' helper had drifted into three copies — LxmfContext (most defensive: handles old anchor falling off the end), MessagesScreen, useMessageNotifications. The notification + screen copies returned [] when the previous head disappeared from the (capped) buffer, silently dropping a window of events on restart. Pulls the LxmfContext semantics into src/utils/sliceNewEvents.ts and points all three callers at it. ARCH P2 #4. * refactor: consolidate relTime formatter WalletScreen + PendingCosigns each carried their own 'X ago' helper. PendingCosigns was missing the days branch so cosign requests over 24h stale would say '36h ago' instead of '1d ago'. Hoists the WalletScreen version (with the days branch) into src/utils/relTime.ts. Spec said WalletScreen + RecentActivity, but RecentActivity actually uses Intl.DateTimeFormat — the duplication is in PendingCosigns. Logged the drift in WORK_LOG. * refactor(network): promote useNetworkMode to context Five active call sites each got their own NetInfo subscription + a fresh MeshRpcAdapter — adapter has a stateful pending-request map and the rpcResponse routing useEffect ran in only one of them, so simultaneous mounts could mute responses for the others (T23). Lifts the entire hook logic into context/NetworkModeContext.tsx with a Provider mounted in app/_layout.tsx inside LxmfProvider (depends on it) and outside WalletBalanceProvider (consumer). src/hooks/useNetworkMode.ts is now a 4-line re-export shim so existing imports stay valid. ROADMAP § 2.6 / ARCH P2 #3.
* fix(wallet): disable device-PIN fallback on all LocalAuthentication calls Anyone with the device unlock pattern could previously extract the seed in four taps — biometric fell through to the device PIN/pattern instead of failing closed. Flips disableDeviceFallback to true on all three LocalAuthentication.authenticateAsync sites in LocalWallet (unlock, sign-time check via readAndDecrypt, create-wallet). Closes AUDIT T13 / THREAT_MODEL T-WALLET-01 / ROADMAP § 0.2. * fix(wallet): drop dead error state in useWalletBalance RecentActivity has read the rate-limit signal off activityError === 'Devnet rate-limited' for a while now. The hook still carried a second error field that set 'Couldn't reach devnet' when every fetch failed — nothing in the app ever read it, so the careful copy was unreachable per AUDIT T24. Removes the dead state from the interface + provider value + refetch reset. The 429 path now flows entirely through activityError so the consumer copy stays canonical. * fix(send): drop hardcoded devnet from SuccessCard + ReviewCard Settlement subtitle and fee secondary line both said 'devnet' verbatim — fine today, wrong the moment mainnet ships. SuccessCard now reads 'Settlement confirmed on-chain.', fee row reads 'Estimated from network RPC'. Banner / explorer link still disclose the active network. * fix(send): silent user-cancel on TransactionNotApprovedError When the user dismissed the wallet popup we still painted an 'Approve the transaction in your wallet' banner — which reads like an error, not a cancel. Solana Mobile's own guidance says swallow those (LESSON 2026-05-13, ROADMAP § 2.3). Branches before setError on isUserCancel and skips it; slider + phase still reset so the user can retry. Real failures still show the error card. * fix(send): MAX chip subtracts fee buffer on native SOL MAX previously paid the full balance, so the resulting tx instantly failed at simulate-time on full-balance wallets. Now reserves a 0.001 SOL buffer on native SOL only — covers signature fee (~5k lamports), priority spikes, recipient rent-exempt minimum (~890k lamports) when sending to a brand-new wallet, and any rounding between uiAmount display and underlying lamport count. SPL paths still send the full token balance since SPL fees come out of SOL. * fix(settings): biometric re-auth on rotate identity confirm Rotate identity is irreversible — old peers can't reach you again. Was triggerable from any unlocked session with one tap. Now requires a fresh biometric prompt before the rotate fires, matching the seed-export gate. Falls back to the existing confirm path if biometric is disabled / unavailable so users don't get locked out. * fix(send): cancel affordance in 'Approve in wallet' footer Once MWA was launched and the wallet popup hadn't returned, the user was stuck staring at 'Approve in wallet' with no way out. Adds a small Cancel under the waiting footer that flips isConfirming back, resets txPhase, and re-arms the slider. UX P1 #15. * fix(onboarding): create-button shows CREATING during create flow CREATE IDENTITY button rendered 'CONNECTING...' once tapped — copied straight from the CONNECT WALLET label. Shows 'CREATING…' instead so the affordance matches the action. D-LANE NOTE: this file is Djason-owned. Branch stays local until Hunter sends the Djason memo at push-time.
* fix(messages): enc:false on insert; flip to true on messageDelivered
Lock icon (enc:true) was set the moment a send was queued, so failed sends and still-in-flight sends both rendered as encrypted on the wire — a present-tense factual claim that isn't true. AUDIT T9 / ROADMAP § 0.3.
Sets enc:false on insert; resolveSeq flips it to true only when the native module emits messageDelivered for the corresponding seq. Reverse-lookup msgId via idToSeqRef so the existing seq→msg map stays load-bearing.
* fix(beacon): require explicit opt-in instead of auto-activating on internet
BeaconRegistry flipped the user into beacon mode silently the first time the device saw internet — beacon mode relays others' traffic and co-signs Solana txs, so opting users in without a tap is a consent violation per AUDIT T10 / ROADMAP § 0.B.3.
Drops the auto-activate useEffect. The Register-as-Beacon control (via setBeaconMode) stays — it's now the only way to enter beacon mode. autoActivatedRef + useEffect import follow it out.
* fix(nodes): drop 7-fake-peer fallback for honest empty state
When LXMF wasn't running yet, NodesScreen filled the radar from a 7-item fixture (constants NODES) — same renderer as live peers, so users saw '@beacon_prime · 12ms' on a totally cold device. That's a lie about current mesh state, not a roadmap signal. AUDIT T7 / ROADMAP § 0.4.
Removes the NODES fixture and the fallback. The radar now shows real peers only; when empty, an overlay reads 'Starting mesh…' (native module available) or 'Mesh unavailable on this device' (no native).
* fix(settings): drop hardcoded 78% BATT pill from paired-hardware card
PairedDevice doesn't carry battery telemetry — the pill rendered '78% BATT' on every paired-device card regardless of actual battery state. Removed for now; when the pairing API exposes a real battery field, swap this back to a live readout. AUDIT T11 / ROADMAP § 0.B.5.
* fix(send): disable Send tile in isolated mode at render-time
Send tile fired `router.push('/send/recipient')` regardless of network state — user picked a recipient, typed an amount, slid to confirm, and only at the confirm step got "no Solana RPC route is available" (ReviewCard.tsx:216-223). Now disables the tile up front when mode === 'isolated' and overlays a NO ROUTE pill so the dead-end is visible from wallet home.
Keeps the confirm-time bail as defense-in-depth for the mid-flow mode-change race. ROADMAP § 2.4 / 02-UX P0 #6.
* fix(beacon): wire Register-as-Beacon CTA to setBeaconMode
Without the auto-activate useEffect (removed in 78a801d), there was no remaining path to flip beacon mode on — the modal's CTA was pointerEvents:none + 'Preview — not yet active'. Replaces the disabled preview with a real Pressable that calls setBeaconMode(true) + dismisses the modal. Stake + biometric co-sign flow remain future work; the JS-side opt-in is the consent gate today.
* fix(messages): also flip enc on cached non-active threads
resolveSeq only updated the currently-rendered msgs array, so if the user navigated away from a thread before native emitted messageDelivered, the sent bubble in threadsRef stayed at enc:false. Reopening the conversation showed no lock even though delivery had been confirmed. Now also walks threadsRef and rewrites any cached thread containing the seq's msgId.
- Debounce LXMF event processing effect to 80ms (matches Kotlin poll interval), collapsing N setPeers calls per batch into one - Wrap setPeers/setNameMap in startTransition so peer-list renders are interruptible, freeing the JS thread to drain incoming events faster - Extract prunePeerMap from the hot event path into a 10s setInterval - Add bleActiveRef updated every render so the 80ms timer reads current bleActive instead of stale closure value — fixes race where BLE announces arrive before setBleActive(true) is reflected in React state - Add blePeerCount effect: when BLE is active and peers are connected, re-tag 0-hop peers as via='ble' and online=true, fixing cached peers showing offline for up to 60s until next announce - Remove NavigationBar.setBackgroundColorAsync on Android — no-op with edge-to-edge enabled; was producing a console.warn every theme change
Magicred-1
added a commit
that referenced
this pull request
May 17, 2026
* fix(lxmf): event-lag mitigations + BLE peer online status (#62)
* Merge Hunter's changes (#19)
* V3 magic branch (#17)
* chore(deps): bump react-native-lxmf to ^0.2.5
* fix(ux): back-navigation lock, exit toast, dedup msg keys, inline new message
- (tabs)/_layout: BackHandler exits app on Android with "press back again to exit"
toast (spring slide-up, themed, 2s window); gestureEnabled: false blocks iOS swipe-back
- _layout: gestureEnabled: false on (tabs) Stack.Screen; fix duplicate onPress prop
- onboarding: instant redirect (0ms) when already connected on mount — prevents
LoadingOverlay flash on back-nav; 2200ms stamp delay preserved for fresh connects
- PeersDrawer: collapse two-view nav into single unified component — hash input + QR
always visible at top, peer list in contained ScrollView; remove NewConvoView split
- MessagesScreen: replace Date.now() IDs with monotonic nextId() — fixes duplicate
key warning when multiple messages arrive in the same millisecond
* fix(tabs): dynamic tab bar height respects system nav inset
useSafeAreaInsets() — height = 56 + insets.bottom, paddingBottom = insets.bottom.
Fixes overlap on Android gesture-nav and 3-button-nav phones.
* feat(wallet): bento grid redesign with balance, network, peers, activity tiles
Replaces flat scrollable layout with bento-style grid. Balance tile shows
SOL + token breakdown with hide toggle. Network tile shows online/mesh/isolated
state. Peers tile shows total peer count + online count. Activity tile renders
tx history with direction-aware formatting.
Fixes font family fallback on Android by using sansBold/sansSb variants
directly instead of fontWeight overrides.
* feat(ui): bento grid redesign for wallet, receive, and send flow
WalletScreen: activity tile fills remaining screen height, pigeon animation
for online mode (RN Animated, no reanimated SVG), NetworkMode import direct
from hook, online color unified to cyan.
ReceiveScreen: full bento-grid redesign matching WalletScreen — tile cards,
kicker header, inline share/copy action tiles, stealth note, neutral/cyan
segmented control tones.
Send flow: SendScaffold, RecipientPicker, AmountKeypad, ReviewCard, SuccessCard
all restyled with bento tiles, consistent kicker+title headers, Feather icons,
no glass hooks.
SegmentedControl: adds neutral tone for non-accent tab states.
* feat(nodes): wire BeaconRegistry to real isBeacon state and live reachable count
- LxmfContext: add isBeacon state (persisted), setBeaconMode (stop+restart with flag)
- BeaconRegistry: replace local active state with context isBeacon/setBeaconMode
- BeaconRegistry: reachableCount from live beacons + online peers instead of hardcoded 3
- BeaconRegistry: disable register button when offline, show "REQUIRES INTERNET"
- storage: add BEACON_MODE pref key
* feat(nodes): detect anonmesh beacon peers from app_data prefix, show BEACON badge
- LxmfPeer: add isBeaconNode flag
- applyAnnounceEvent: detect 'anonmesh::beacon::v1\0<name>' prefix, extract display name after null separator
- mergeBeacon: always mark beaconDiscovered peers as isBeaconNode
- peerToMapNode: pass beacon flag to NodeData
- NodeRow: show BEACON pill next to handle when n.beacon is true
* fix(peers): full-width search bar with larger tap target in PeersDrawer
* feat(chat): media sending + queue state indicators on message bubbles
- Composer: image picker button (expo-image-picker), MediaPayload callback
- MediaBubble: display received/sent images, tap-to-fullscreen lightbox
- types: MediaMsg type, AnyMsg union extended
- MessagesScreen: handleMedia sends base64-encoded image over LXMF
- parseStructuredMsg: decode incoming {t:'media'} envelope to MediaBubble
- MessagesScreen: seqStates map tracks queued/delivered/failed per-seq
- MessageBubble: sendState prop with icon+label (sent/queued/delivered/failed)
- Pending banner when messages are queued waiting for peer
- app.json: NSPhotoLibraryUsageDescription + expo-image-picker plugin
* feat(chat): migrate to lxmf module API, per-peer threads, file attachments, error banner
API migration (messageReceived):
- Drop old msgpack wire decode (decodeLxmfSender/decodeLxmfContent/e.content)
- Read e.source (sender hash), e.body (base64 UTF-8), e.image, e.files directly
- Inbound images now come via e.image → MediaBubble; removes JSON-body media hack
- Inbound file attachments rendered as tappable rows with name + size in MessageBubble
Media send:
- Pass image as LxmfMedia 3rd arg to send() instead of JSON-encoded body
- send/broadcast types updated in LxmfCtxValue to include media?: LxmfMedia
Per-peer thread persistence:
- threadsRef (Map<destHash, AnyMsg[]>) stores messages per conversation
- pickPeer saves current thread before switching, restores saved or inits fresh
- Incoming messages for non-active peer routed to their thread directly
QR scan UX:
- Scan result immediately starts conversation (removed intermediate contact card + button)
Composer/keyboard:
- Composer bar and field padding reduced (fits tighter)
- Field borderRadius 99→12 (matches icon squircle style)
- Android keyboard: softwareKeyboardLayoutMode=pan + KAV behavior=undefined on Android
Error banner:
- LxmfErrorBanner reads error from LxmfContext, floats above tab bar
- Dismissible per distinct error string, auto-reappears on new error
* fix(android): keyboard overlap — resize mode + hide tab bar on keyboard
* fix(chat): remove dead try/catch, mark failed sends on bubble instead of sys message
send() in useLxmf catches all native errors internally, sets lxmf.error, returns -1.
The try/catch in sendMsg/handleMedia never fired. pendingRef retry logic was unreachable.
- Remove pendingRef + announce-retry useEffect (dead code)
- Remove try/catch from sendMsg and handleMedia
- On seq === -1: write pseudoSeq (-msgId) into seqStates as 'failed' so the
bubble shows the failed indicator; lxmf.error banner shows the actual reason
- handleGridAction: drop unreachable .catch()
* fix(notifications): use e.source directly, skip pickPeer when already in thread
useMessageNotifications was decoding srcHash from e.content (old wire format) which
doesn't exist in the new API — e.content was always empty string, so decodeLxmfSender
returned null, srcHash fell back to e.source but via a broken path. Suppression check
(srcHash === activeConversationRef) never matched → banner fired even for active peer.
useFocusEffect was calling pickPeer unconditionally on notification tap, resetting the
thread even when the peer was already active. Guard added.
* fix(chat): treat seq=0 as success — only seq<0 is a send error
useLxmf.send() returns -1 on error, passes through native seq otherwise.
Native module may return seq=0 for queued-without-route messages that still
get delivered. Previous check (seq > 0) falsely marked these as failed.
Now: seq < 0 → failed bubble, seq > 0 → tracked with timer, seq = 0 → optimistic.
* fix(chat+notifs): display names, thread routing, keyboard, lxmf bump
- getDisplayName reads knownPeersRef directly (bypasses stale React state)
- Notification dispatch deferred via setTimeout(0) so parent peer-tracking
effect populates knownPeersRef before display names are resolved
- Thread routing: flip condition to positive form; sync activePeerHexRef
immediately in pickPeer to close race window on peer switch
- useFocusEffect fallback uses getDisplayName instead of @hash prefix
- Inline from resolution uses getDisplayName (ref) instead of lxmfPeers.find
- keyboard: pan mode + KAV padding + safe-area spacer collapses on keyboard
- bump @magicred-1/react-native-lxmf 0.2.25 → 0.2.26
* Polish mobile bottom navigation (#18)
* feat: polish mobile bottom navigation
* fix: keep polished tabs responsive
---------
Co-authored-by: Excelsior <33706074+epicexcelsior@users.noreply.github.com>
* feat(android): foreground service keeps LXMF alive in background
- plugins/withAndroidForegroundService.js: config plugin writes LxmfForegroundService.kt (START_STICKY), LxmfServiceModule.kt (NativeModule start/stop), LxmfServicePackage.kt, patches MainApplication.kt, adds FOREGROUND_SERVICE + FOREGROUND_SERVICE_CONNECTED_DEVICE permissions + service declaration (foregroundServiceType=connectedDevice)
- hooks/useBackgroundService.ts: starts service on mount, stops on unmount, Android-only
- app/_layout.tsx: call useBackgroundService() in AppShell
- app.json: register plugin; add fetch + processing to iOS UIBackgroundModes
* fix(scripts): increase announce delay 1s→4s so phone processes it before message arrives
* fix(rnode): gate phase 3 on real bond success, not fake timer
- connect(): check pairNusRNode() boolean — false/throw → error msg, stay phase 1
- phase 2: poll getNusUnpairedRNodes() every 500ms until pairedMac disappears from list (= OS bond + GATT connected); 15s timeout → error back to phase 1
- show pairError in phase 1 hint text
- remove fake 1800ms setTimeout that showed success regardless of outcome
* feat(messages): group channels — create, join, share, members
- LxmfContext: add groups state, createGroup/joinGroup/leaveGroup/getGroupMembers,
auto-register groups on node restart, route sendGroup for group dest addrs
- PeersDrawer: CHANNELS section with JOIN/CREATE pill buttons, group rows with
members icon, long-press to leave; separate DM peers from group peers
- CreateGroupModal: bottom-sheet, input → done phase with QR code + copy rows
- JoinGroupModal: bottom-sheet, prominent SCAN QR CODE button, manual addr+key entry
- GroupMembersSheet: bottom-sheet listing active senders derived from message history
- ChannelShareSheet: QR code + copy addr/key/pair for sharing channel join info
- ThreadHeader: share-2 icon triggers ChannelShareSheet when active peer is a group
- QRScannerModal: extend ScannedAddress with lxmf-group type, parse lxmf://group/… URIs
- Composer: swap arrow-up → bird icon (MaterialCommunityIcons), fix safe-area
bottom spacer background color to match bar (surface0)
- MeshMap: expand by default on mount
- lxmf bump 0.2.43 → 0.2.53
* feat: swipe-to-leave channels, pigeon send icon, trim LoadingOverlay, README
- PeersDrawer: SwipeableGroupRow with pan gesture — swipe left reveals red
LEAVE action; removes onLongPress + users icon + onShowMembers prop
- MessagesScreen: drop GroupMembersSheet, membersAddrHex state, onShowMembers
- LoadingOverlay: rewrite from 267 → ~100 lines, drop passport stamp phase,
same loading card + dots + cursor blink animation
- Composer: bird icon (MaterialCommunityIcons) replaces arrow-up send button;
safe-area spacer background fixed to surface0
- MeshMap: expanded by default on mount
- README: project manifesto — messaging, channels, mesh networking, Solana
wallet, identity/privacy, install, architecture, compatibility + banner
* feat(ux): Signal-style messaging, pan-back gesture, nodes redesign, FGS crash fix
MessagesScreen / PeersDrawer — full rewrite:
- Removed slide drawer; list IS the screen when no peer selected (two-state)
- Picking a peer slides chat in from right (spring); swiping right or tapping
the back arrow slides it back out — same Reanimated withSpring animation
- Unified conversation list (groups + DMs, unread-first sort, circle avatars)
- JOIN / CREATE channel always visible; JOIN has solid border, CREATE has filled
primary background — high contrast, no toggle needed
- Hash input and search always visible, no toggle
- SwipeableGroupRow: left-only gesture, velocity-aware snap, animated icon scale
(interpolate 0.64→1.0 as you reveal), deeper red (#c0392b)
NodesScreen:
- Removed LINKED PEERS section; filter chips overlaid on MeshMap (absolute bottom)
- BeaconRegistry fills remaining screen height (flex: 1) with beacon explanation
MeshMap:
- Removed floating EXPAND pill (was leaking past Animated.View overflow on Android,
overlapping BeaconRegistry); header maximize-2 button still opens fullscreen
- Full header row is now a Pressable for expand/collapse (wider tap target)
- Chevron bumped to size 14, textSecondary color for better visibility
Android FGS crash (SecurityException):
- startForeground now passes FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE on API 34+
- Added CHANGE_NETWORK_STATE / CHANGE_WIFI_STATE to manifest to satisfy the
"at least one of" requirement for connectedDevice type (auto-granted, no prompt)
* feat: UX polish, MeshMap overlap fix, crypto hardening, Android CI release
Settings:
- Rename labels for clarity (pair with hardware, biometric lock, cellular
fallback, rotate identity keys, export wallet key, message notifications)
- Remove connection priority row
MeshMap:
- Fix node overlap: two-step layout enforces MIN_ARC_GAP (44px) per ring
and CROSS_RING_GAP (36px) between adjacent rings
- filterRow render prop: filter chips appear in both collapsed and fullscreen
views; selStripBottom prop keeps node strip above chips
- selStrip no longer covered by filter overlay in NodesScreen
Security:
- generateKeyHex: replace Math.random fallback with ExpoCrypto.getRandomBytes
(expo-crypto was already a dep, fallback was silently insecure)
CI:
- Add .github/workflows/release-android.yml: push to v3 triggers expo
prebuild → signing patch → assembleRelease → GitHub release with APK
- Gitignore: add *.keystore rule to mobile_app/.gitignore
* fix(ci): strip whitespace from KEYSTORE_BASE64 before base64 decode
* fix(ci): use [:space:] class to strip all whitespace from KEYSTORE_BASE64
* fix(ci): use --ignore-garbage to handle invalid chars in KEYSTORE_BASE64
* fix(android): pass FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE on API 34+ in service template
* chore: bump version to 1.0.1
* feat(messages): sonar empty state + BLE permission request on focus
- NoPeersScreen: 3 staggered sonar rings with app icon when no peers
- Replaces PeersDrawer when peer list is empty; New/Join group chips shown
- BLE permissions requested via useFocusEffect on MessagesScreen mount
- PeersDrawer always receives full livePeers array (no undefined guard)
* fix(android): add BT+WiFi permissions to FGS plugin manifest — connectedDevice type requires at least one
* fix(android): switch FGS type connectedDevice→dataSync — connectedDevice requires runtime BLE grants at startForeground() time which aren't held at launch
* feat(ux): messages instant transition, UTF-8 fix, nodes polish, stake modal
Messages:
- Always mount chat panel off-screen (translateX: screenW) so peer-pick
transition is pure UI-thread animation with zero mount cost
- Switch spring→withTiming (240ms easeOut in, 200ms easeIn out) for
predictable snappy feel
- Move GestureDetector to wrap only scroll+composer so ThreadHeader
back Pressable receives taps on Android
- Fix garbled text from external LXMF peers: decodeBody now tries
base64 first, validates readability, falls back to raw UTF-8
Nodes:
- PendingCosigns: new horizontal snap-scroll card carousel with dot
indicators, screen-width-aware sizing, co-sign + reject actions
- MeshMap: expose onExpandChange prop so NodesScreen hides filter
chips when map is collapsed (no overlap)
- BeaconRegistry: stake modal with amount stepper, rep-score + yield
impact preview, biometrics CTA; auto-activates beacon on internet
Wallet:
- Bird icon (MaterialCommunityIcons "bird") on online/mesh status chip
- Bird icon for empty recent-activity state
- Fix unused useRef lint, extract netLabel nested ternary
* fix(android): purge stale FGS service entries before re-adding — prevents connectedDevice duplicate surviving across prebuilds
* feat(ux): unified search/hash bar, peer DM button, iface icons, stake TextInput
- PeersDrawer: merge search + paste-hash inputs into one smart field — auto-detects hex hash (≥16 chars) vs search query; camera always visible, X clears on demand
- MeshMap: replace selection ring + online dot with interface-type badge (wifi/bluetooth/radio-tower) top-right of each peer node; add DM button to selected-peer strip that navigates to Messages with that peer pre-opened
- NodesScreen → MessagesScreen deep-link: router.navigate to /(tabs) with destHash+handle params; MessagesScreen picks them up via useLocalSearchParams + useFocusEffect and calls pickPeer
- BeaconRegistry: stake stepper TextInput replaces static Text — live preview of rep/yield impact while typing; KeyboardAvoidingView wraps stake modal; bigger SOL icon (14→28), bigger hero icon (16→26), impact icon sizes 12→14
- PendingCosigns: moved to WalletScreen; fixes double-padding (H_PAD removed, WALLET_PAD=32 for cardW calc); compact card dimensions (padding 16→14, amount 34→26, actions 44→40)
* chore(brand): lowercase "anonmesh" in user copy + tutorial replay scripts
Lowercases all user-visible AnonMesh -> anonmesh (8 strings):
- tutorial slide title, header fallback, final CTA
- receive.tsx + solanaPayUri.ts Solana Pay label/message defaults
- MeshMap empty-state copy
- blePermissions internal comment
Adds two npm scripts that deep-link the tutorial route:
- replay:tutorial:android (adb am start)
- replay:tutorial:ios (xcrun simctl openurl)
Lets any teammate replay the first-run tour on a running device
without pm clear or rebuilding. No app-code changes.
* polish(tutorial): switch slide icons to standard Feather set
Slide 1 (identity): identity-chip -> user
Slide 2 (evidence/radio): signal -> radio (matches statLabel "Radio")
Drops two custom SVGs in favor of Feather glyphs that read cleaner
at the 42px tutorial size and align with icon style used elsewhere
in Settings. Slide 3 (send) already used Feather.
* refactor(send): drop DEMO_MODE flag, route demo prefill through deep link
Demo affordances must not ship in the production bundle even as inert
dead code. EXPO_PUBLIC_DEMO_MODE was env-driven and could be flipped on
for any release build, leaking demo-time behavior to anyone holding
that APK.
- Remove src/utils/demoMode.ts (DEMO_MODE + DEMO_RECIPIENT_ADDRESS)
- RecipientPicker: read recipient from ?to= query param via
useLocalSearchParams; drop hardcoded prefill, demo fill button,
and orphan styles; tighten QR-soon alert copy
- ReviewCard: drop "Demo mode: devnet SOL only" banner + orphan styles
- Add demo:prefill-send:{android,ios} npm scripts wrapping the
anonmesh://send/recipient?to=<addr> deep link with the same demo
address as the previous flag
No regression on tab navigation: /send/recipient with no params still
opens to an empty address field (identical to DEMO_MODE-off behavior).
* fix(wallet): drop stale H_PAD ref crashing PendingCosigns
The component's own comment notes that horizontal padding is owned by
WalletScreen's grid (paddingHorizontal: 16, mirrored by WALLET_PAD = 32
in the cardW math). The H_PAD reference on the wrapper View was dead
code left over from an earlier refactor — at runtime it raises
"Property 'H_PAD' doesn't exist" the moment the wallet tab mounts.
Drop the inline paddingHorizontal entirely.
* fix(messages): unblock composer taps + balance icon sizes
Composer's Pressables (grid, image, send/bird) sat inside the back-pan
GestureDetector. Pan + RN Pressable both grab the touch responder, so
the inner taps silently lost while TextInput (native, bypasses the JS
responder) kept working — hence "keyboard send works, bird button
doesn't, other icons feel like placeholders." Move Composer outside
the GestureDetector so the swipe-back gesture only covers the message
list.
While here, bump composer icon sizes (15→18, 18→20) and button radii
to align with the field, and add hitSlop=8 on each Pressable for less
finicky taps on the 36pt targets.
* fix(messages): asymmetric composer hitSlop so adjacent buttons can't steal taps
Grid + image buttons sit in a 10pt gap with hitSlop:8 on each. 8+8>10
means the touch regions overlap by 6pt in the visual gap — taps in that
strip flip to whichever Pressable wins JS responder negotiation, which
isn't deterministic for sibling Pressables. Cut the inner-edge hitSlop
to 4pt; outer edges keep the full 8pt for tap-target comfort.
Drive-by: drop the stale H_PAD reference in PendingCosigns (same fix the
other open wallet-lane PRs carry — no-ops on whichever merges first).
* fix(nodes): drop stale H_PAD reference in PendingCosigns
PR #29 removed the H_PAD constant when moving PendingCosigns into
WalletScreen's grid (parent now owns horizontal padding) but left one
reference at line 57. Result: upstream/v3 fails tsc on a fresh clone.
Drop the stale paddingHorizontal entry from the wrap View style array
and consolidate the two duplicate @expo/vector-icons imports while in
the file.
* feat(wallet): expose per-tx decimals in activity entry
Renderers need decimals to format SPL amounts with the right precision
ceiling. Native SOL keeps the canonical SOL_DECIMALS value; SPL deltas
carry the mint's reported decimals through from the parsed token
balance entry.
No render change in this commit — wires the data through so the
activity tile and tx detail can stop assuming SOL precision.
* fix(wallet): activity tile renders real symbol and decimals
The wallet bento's inline activity tile hardcoded the literal 'SOL'
under every row and rounded amounts to four decimals regardless of the
token. SPL transfers landed on screen labelled as SOL with the wrong
precision — every USDC/USDT/BONK/JUP send showed as 'X.XXXX SOL'.
Read tx.symbol off the activity entry so the label reflects the actual
token, and format the amount via the existing fmtAmount helper using
the per-tx decimals from the parser. Native SOL formatting is
unchanged since SOL_DECIMALS round-trips through the same path.
* feat(wallet): tap activity row to open tx detail
Wraps each activity tile row in a Pressable that opens the existing
TxDetailModal with the full signature, counterparty, slot, fee, memo,
and mint when applicable. Light haptic feedback on tap matches the
balance refresh and send buttons.
Picks up the work that landed TxDetailModal in the wallet bento on the
home composition (RecentActivity already tapped through there) and
extends the same affordance to the inline tile that the wallet screen
actually renders. Reviewer can copy the full tx signature out of the
drawer or jump straight to Explorer.
* fix(wallet): redact activity row a11y label when balance hidden
When balance hide is on, the row text shows ••• but the
accessibilityLabel still spelled out the real amount and symbol,
leaking the hidden balance to screen readers and a11y tooling.
Conditionally render a redacted label when hidden.
* fix(wallet): TxDetailModal close+explorer buttons fire; copy rows show check feedback
Two issues caught on Seeker smoke:
1. Close and Explorer DepthButtons silently swallowed taps. Layout had
a Pressable absoluteFill sibling to the SafeAreaView sheet — on
Android that absoluteFill intercepted touches that should have hit
the nested DepthButton Pressables inside the sheet. Refactored: the
backdrop is now itself a Pressable (onPress=onClose), the sheet is a
nested Pressable that absorbs taps with onPress=noop so they don't
bubble up and trigger dismiss. Inner DepthButtons receive presses
normally.
2. Copy buttons on detail rows had no feedback — tapping a sig/memo/mint
row copied silently and the user had no signal it worked. Added per-
row copied state with 1400ms timeout, swapping the icon copy→check
and tinting label/icon colors.primary, matching the pattern used in
ReceivePanel.tsx for the wallet address copy.
* fix(wallet): TxDetailModal buttons fire — split dismiss area from sheet
Previous fix (8349632) wrapped the sheet in a noop Pressable to absorb
taps. On Android the parent Pressable still claimed the responder
before nested DepthButton Pressables could activate, so close/explorer
remained dead.
Switched to the canonical bottom-sheet pattern: dismissArea is a flex:1
Pressable that occupies only the space above the sheet (column flex
with justifyContent:flex-end pushes the sheet to bottom; the dismiss
area takes the remaining space). The sheet itself is a plain
SafeAreaView with no Pressable wrapper, so DepthButton's onPress
receives presses directly without any parent responder competition.
Tap-on-backdrop still dismisses (dismissArea fills everything above
the sheet); tap-on-sheet does not bubble (no overlap).
* fix(wallet): swap DepthButton for plain Pressable in TxDetailModal actions
Despite split-pattern layout (028e1d0) DepthButton's onPress still did
not fire on Seeker for Close/Explorer. Plain Pressable on detail rows
in the same ScrollView fires correctly, so the responder issue is
DepthButton-specific in this Modal+ScrollView context — likely the
nested Animated.View + LinearGradient stack swallowing the touch.
Replaced with two plain Pressables styled to match the visual hierarchy
(secondary outline for Explorer, filled primary for Close). Min-height
44, hit-target safe. Pressable feedback via opacity, no animation
machinery to interfere with the responder.
Also swapped expo-web-browser openBrowserAsync for Linking.openURL,
matching SuccessCard's existing handleExplorer pattern. Linking is the
RN-native path; one less native module in the touch handler chain.
* chore(wallet): clean up TxDetailModal — drop empty style, refresh stale comment
After the DepthButton swap (0a9adaa), the actionBtnPrimary StyleSheet
entry was left as {}, and the layout comment still referenced
DepthButton. Drop the empty style + its consumer site, rewrite the
comment to describe the actual current pattern.
* fix(wallet): clean up TxDetailModal copy-feedback timer + a11y
Track the COPY_FEEDBACK_MS reset timer in a ref so:
- rapid re-taps clear the stale timer before scheduling a new one (prevents
the earlier timer flipping copied back to false mid-window)
- the cleanup effect clears it on unmount (prevents the late setCopied call
on an unmounted component when the modal closes within 1.4s of a copy)
Mark the dismiss-area Pressable as accessible={false}. The visible Close
button at the bottom of the sheet is the screen-reader path; an unlabeled
backdrop just adds a phantom focusable region.
* style(send): lowercase step titles for visual consistency
Send-flow scaffold titles were title-case (Set amount, Review, Transfer
in motion) which read as shouty in a UI where the rest of the heading
hierarchy is lowercase. Lowercased to match the AnonMesh chrome voice.
Subtitles (sentence-case copy) and the kicker (textTransform:uppercase
in CSS) untouched. RecipientPicker's screen title was already lowercase.
Wave A.3 from POLISH_PLAN.
* fix(security): require screen-capture block before revealing recovery key
ExportWalletModal previously fired preventScreenCaptureAsync inside a
mounted-effect and forgot the result. The .catch(() => undefined)
swallowed any rejection — older OS, sandbox issue, race with another
capture-protected screen — and the modal still rendered the secret
with its "SCREENSHOTS BLOCKED" hint while screenshots were in fact
fully unblocked. The user could leak their base58 recovery key in a
single screenshot with no UI signal that protection had failed.
Track the prevention promise's resolved state across three values:
pending, blocked, unavailable. On unavailable, refuse to render the
secret entirely (replace KeyBox with an "Secure window unavailable"
explainer panel) and gate authenticate() so the biometric prompt
never fires. On AppState foreground, re-apply prevention because
older Android versions can drop FLAG_SECURE while the app is
backgrounded; if re-application fails, scrub any rendered secret
immediately. KeyBox grows a captureReady prop and shows a
"PREPARING SECURE WINDOW…" panel while pending so the user does
not authenticate before the secure window is in place.
Cleanup of the AppState listener and allowScreenCaptureAsync() runs
on unmount as before.
* fix(settings): surface real recovery export failures inline
WalletContext.exportPrivateKey returned `string | null` and folded
two distinct outcomes into the null case: user-cancelled biometric
(silent intent) and real system failure (keychain read failed,
decrypt failed, etc.). On a real failure it surfaced the reason
through a separate Alert.alert("Export failed", msg) popup that
appeared on top of the modal sheet, while the modal itself moved
to a generic "KEY UNAVAILABLE / Try again when ready" failed-state
card. The user got "try again" alongside an alert dialog the modal
was visually covering — so they retried, hit the same failure,
saw the alert again. Recovery is the highest-trust surface in the
app; that experience erodes confidence in the wallet itself.
Change the return to a tagged union: { ok: true; secretKey } for
success, { ok: false } for user-cancel (no message, modal stays in
initial state silently), { ok: false; message } for real failures.
The modal stores the message in failMessage state and KeyBox renders
it inline below the KEY UNAVAILABLE label, with a TAP TO RETRY
affordance on the same surface. Drop the Alert.alert call.
* fix(receive): accept comma decimal in solana pay amount
Solana Pay spec mandates "." as the decimal separator in the amount
URI parameter. Locales that surface a decimal-pad keyboard with
comma (de-DE, fr-FR, pt-BR, es-ES) emit "0,5" from the receive
amount field. The previous regex rejected the comma and the URI
was built with no amount= parameter at all, so any wallet scanning
the QR (Phantom, Solflare, Backpack) prefilled with no amount and
the user got a "no amount requested" experience even though they
had explicitly entered one.
Swap "," for "." inside normalizeAmount before regex validation.
The output URI is always spec-compliant. Three locale-comma cases
added to validate-tier0-services covering plain comma, comma plus
trailing zeros, and the whitespace-padded comma shape that real
TextInput delivers on keyboard dismiss.
* fix(security): close captureBlock race on AppState resume
applyBlock() didn't reset captureBlock to 'pending' before re-applying
prevention on foreground, so the in-flight window between resume and
confirmed preventScreenCaptureAsync() could render the secret with
stale 'blocked' state — screenshots possibly unblocked while UI claims
otherwise.
Three changes, belt-and-suspenders:
- Pessimistically reset captureBlock to 'pending' at top of applyBlock
so any in-flight reapply visibly gates render and auth.
- Extend the secret-scrub effect to fire on any non-'blocked' state
(was 'unavailable' only). Drops the in-memory secret on resume so a
brief stale frame can't leak the key.
- KeyBox renders 'preparing secure window' whenever captureReady is
false, regardless of whether a stale secret prop survived a render.
Also adds the multi-comma rejection assertion the PR body promised:
'1,2,3' must not produce amount= in the Solana Pay URI.
* fix(settings): clear keyCopied flag when scrubbing recovery secret
Without this, the 1.4s "COPIED" badge survived a copy → background → resume
cycle: capture-block dropped to pending, secret was scrubbed, but keyCopied
state remained true. Next reveal flashed a stale COPIED badge on a freshly
re-authenticated session.
Also clear keyCopied at the start of authenticate() as belt-and-suspenders
in case a user retries faster than the 1.4s timeout.
Add a recipient-only URI test to make the no-amount path explicit, and
reword the multi-separator test comment so it doesn't claim locale-grouped
numbers (1.234,56) are universally invalid — they aren't, but the receive
screen never feeds them.
* feat(primitives): AppBottomSheet — gorhom-backed standard sheet primitive
Add @gorhom/bottom-sheet@^5 as the canonical bottom-sheet engine.
Wraps it as components/primitives/BottomSheet.tsx exposing our visual
chrome (handle indicator, glass surface, primary border accent,
backdrop tied to drag progress) with a declarative visible/onClose
API consumers already know.
What gorhom buys:
- Pan-from-anywhere on the sheet (Apple convention; not handle-only)
- Native-thread gesture + animation (no JS lag under load)
- Scroll-to-dismiss handoff for sheets with internal scroll
(BottomSheetScrollView/FlatList re-exported from this module)
- Flick-to-dismiss with velocity projection
- Snap points (omit prop = dynamic content-fit sizing)
- Keyboard-interactive layout for sheets with TextInput
- Modal portal — render anywhere in the tree, sheet floats above
Wired BottomSheetModalProvider inside GestureHandlerRootView in the
root layout so every screen can present sheets without local provider.
BottomSheetHandleBar exported as a standalone bar for surfaces that
can't move to AppBottomSheet yet (route-style modals like the receive
screen). Visual matches the gorhom handle indicator.
* refactor(sheets): TokenPicker + receive route adopt new sheet primitives
Two surfaces migrate off bespoke gesture/animation scaffolding onto the
canonical primitives in this PR:
TokenPicker (components/send/TokenPicker.tsx):
- Wraps content in <AppBottomSheet visible onClose>; drops Modal,
GestureHandlerRootView, GestureDetector, Animated.View, Pressable
backdrop, in-line grab bar, panGesture, dismiss callback,
DISMISS_DISTANCE/DISMISS_VELOCITY constants, sheetStyle/translateY,
useGlass.
- Result: -120 LOC; Apple-feel pan-from-anywhere, scroll-handoff if it
ever grows scrollable content, keyboard-interactive for free.
Receive route (app/receive.tsx):
- Was using legacy PanResponder (JS-thread → laggy under load on
Seeker). Replaced with react-native-gesture-handler Pan +
Reanimated worklet writes — translateY now updates on the UI thread
so the sheet follows the finger 1:1.
- Visual handle swapped to <BottomSheetHandleBar/> from the primitive
module so the receive route matches the gorhom-rendered indicator
used elsewhere.
- Velocity threshold corrected (was 0.8 in PanResponder dy/ms units;
gesture-handler uses px/s — now 800 to match other surfaces).
- Inline GrabHandle helper deleted.
- Receive stays a Stack.Screen route (presentation: 'modal') for now;
conversion to a true gorhom BottomSheetModal mounted from the
wallet bento is a future refactor.
* fix(primitives): BottomSheet uses fixed snap points + explicit index
Dynamic content sizing in @gorhom/bottom-sheet v5 has a measurement
race on first present — BottomSheetView reports 0 height before
children mount, the modal opens at height 0 then never measures again.
TokenPicker presented but rendered invisible.
Lock to a single 85% snap point by default with enableDynamicSizing
disabled. Consumers can pass snapPoint='60%' etc. for a different
height. index={0} explicit so present() always opens to the first
(and only, by default) snap.
Future work: revisit dynamic sizing with onLayout callback once we
have a sheet that genuinely needs content-fit height (TxDetailModal
post-merge — variable content based on tx fields).
* fix(primitives): replace gorhom with roll-our-own AppBottomSheet
Gorhom's BottomSheetModal was firing present() but rendering invisible
in our setup — likely a v5 dynamic-sizing/portal interaction with
Reanimated 4 + expo-router. Couldn't reproduce the working state from
the docs in our tree. Pivoting to a primitive built on
react-native-gesture-handler + Reanimated directly so we control every
piece and can debug end-to-end.
What the new AppBottomSheet does:
- Native RN Modal as the platform overlay (transparent + animationType=none).
- gesture-handler Pan with activeOffsetY([12, screenHeight]) so the
sheet only claims the gesture on downward intent past 12pt — inner
Pressables/inputs still receive their taps.
- Reanimated worklet writes translateY directly during drag so the
sheet follows the finger 1:1 on the UI thread (no JS lag).
- Spring physics damping 22 / stiffness 250 (open) and 320 (back) for
a snappy Apple-adjacent feel.
- Velocity-aware dismiss: drag past 120pt or fling > 800px/s closes;
short release springs back.
- Backdrop opacity interpolates 0 → 0.7 with translateY progress so the
dim fades naturally as the sheet leaves.
- Internal mounted state lags the visible prop on close so the slide-
down animation finishes before the Modal unmounts (without it the
sheet would just snap off-screen).
- Split dismiss-pattern: dismiss-Pressable is flex:1 above the sheet
(no overlap with sheet area), backdrop dim is a separate
pointerEvents:none Animated.View. Same fix pattern as TxDetailModal
post-debug — avoids absoluteFill-sibling responder claims that
silently swallow nested DepthButton/Pressable taps.
Removed BottomSheetModalProvider from app/_layout.tsx since it's no
longer needed (and may have been interfering with the wallet-screen
QR button's native Modal — to be verified on rebuild).
Removed @gorhom/bottom-sheet from package.json. Removed the
BottomSheetScrollView/BottomSheetFlatList re-exports from the
primitives index since they were gorhom-specific. AppBottomSheet
+ BottomSheetHandleBar remain — same public API as before.
* fix(primitives): TokenPicker pan-down + open animation
Two bugs from the previous primitive build:
1. activeOffsetY([12, SCREEN_HEIGHT]) — array form means "activate
when Y translation is OUTSIDE this range", i.e. < 12 OR > screen
height. The < 12 case fires on UPWARD pan, not downward — exactly
the opposite of what a pull-down dismiss needs. Switched to
activeOffsetY(12) (single positive number = downward past 12pt
activates).
2. withSpring(0, { damping: 22, stiffness: 250 }) over SCREEN_HEIGHT
travel was underdamped (ratio ~0.7) so the sheet overshot past
the rest position and oscillated — "shoots way up, slow." Apple's
modal sheet open isn't a spring; it's a timing curve. Switched
open + close to withTiming with Easing.out/in cubic (320ms / 220ms).
Spring kept only for snap-back after a partial interactive drag —
short distance there, no overshoot risk, feels natural.
Receive screen still uses single-number activeOffsetY (was already
correct), so it gestures down. Lag user reported there is the native
modal back-animation conflicting with our local dismiss animation —
deferring that fix to a follow-up since it requires changing the
route's presentation config.
* fix(primitives): wrap Modal contents in GestureHandlerRootView; drop blue line
Two more bugs from device smoke:
1. TokenPicker still couldn't gesture down even with activeOffsetY(12)
because RN Modal renders contents in a SEPARATE NATIVE VIEW TREE
(Window/Dialog on Android, separate UIWindow on iOS). The app-root
GestureHandlerRootView at app/_layout.tsx does NOT propagate into
that tree, so GestureDetector inside the modal saw no gestures.
Documented in react-native-gesture-handler README under "Using
inside Modal". Added a GestureHandlerRootView wrapping the Modal
contents — gesture context now lives inside the modal's native
tree where children can find it.
Receive screen worked because it's a Stack.Screen route (same
gesture-handler context as the rest of the app), not an RN Modal.
2. "Weird blue line at top" of the sheet was borderTopWidth: 1 with
borderColor defaulting to colors.borderStrong, which is
rgba(0,229,255,0.28) — cyan. Switched default to colors.border
(subtle) and dropped width to StyleSheet.hairlineWidth so the
divider is barely visible, not a feature accent.
Also tightened the docstring to reflect the actual behavior (timing
for open/close, spring only for snap-back) and pulled the
GestureHandlerRootView wrap requirement up as critical wiring so
future maintainers see it before they break it.
Standard primitive: every new sheet in the app should use AppBottomSheet.
No per-screen ad-hoc gesture/animation rewrites.
* perf(receive): smooth out swipe-to-dismiss jitter
Two changes targeting the 'tiny bit of lag/jitter' user reported on
the receive screen pan-to-dismiss:
1. Sequence the dismiss animations. Previously: pan releases past
threshold → translateY = withTiming(800) AND router.back() fire
simultaneously. Our local timing animates inner content down to
800px while the native back-animation slides the WHOLE screen
from full position to off-screen — two transforms compound into
visible jitter.
Now: withTiming completion callback fires router.back. By the
time native back kicks in, our inner is already off-screen, so
the native animation runs invisibly. One animation per frame.
2. renderToHardwareTextureAndroid + collapsable={false} on the outer
Animated.View. The receive screen has a non-trivial subtree
(QR svg, segmented control, two action buttons, an input). On
Android, transforming that subtree per-frame asks the GPU to
recomposite the whole layer. The hardware-texture hint caches
the view as a GPU texture so per-frame translateY is cheap.
collapsable={false} prevents Android's view-tree optimizer from
collapsing the wrapper layer (which would defeat the texture
cache).
No behavior change — same dismiss threshold (120pt or 800px/s),
same spring snap-back. Just smoother on Android.
* perf(receive): transparentModal route → kill black-flash after dismiss
User flagged: after our pan-to-dismiss slides receive content off-
screen smoothly, a black screen still slid down behind it. Cause: the
Stack-screen route container has a default dark background. After our
inner Animated.View timing reached 800px, the container itself was
still full-screen with its own (dark) background. router.back then
fired native back animation — sliding the empty dark container down
over the wallet screen. Visual: extra black flash.
Switch presentation from 'modal' to 'transparentModal'. Container
background is transparent → after our inner content slides off, the
empty container is invisible → native back animation runs over a
transparent layer → user sees wallet screen behind during back, no
black flash. Receive content keeps its own backgroundColor, so the
visible area looks identical during entry and use.
* perf(receive): own enter+exit animation; kill all native modal motion
User reported a 'little black box at bottom that waits' after swipe-
down dismiss, even with transparentModal. Cause: the native back
animation was still slid the empty Stack-screen container down behind
our animation, and any height mismatch (window-height translate vs
screen-height container) left a sliver of content visible at the
bottom edge.
Solid architecture: receive owns ALL motion via Reanimated; the route
itself runs zero native animations.
Three changes:
1. app/_layout.tsx receive Stack.Screen options:
- presentation: 'modal' → 'transparentModal' (transparent container)
- animation: 'slide_from_bottom' → 'none' (no native enter/exit)
- contentStyle: { backgroundColor: 'transparent' } (the screen's
own content layer is also transparent)
The route is invisible at every phase; only our own Animated.View
has a backgroundColor and only it ever moves.
2. app/receive.tsx initialization:
- dragY initial value: 0 → SCREEN_HEIGHT (start off-screen)
- useEffect on mount: withTiming(0, ease-out cubic, 320ms) — slides
the content UP from off-screen into rest position. Replaces the
native slide_from_bottom we just disabled.
- Dismiss timing already sequenced via callback — slides DOWN to
SCREEN_HEIGHT then fires router.back which now unmounts instantly
(animation:'none'), invisible to the user.
3. SCREEN_HEIGHT source: Dimensions.get('window') → Dimensions.get('screen')
in BOTH receive.tsx AND BottomSheet.tsx primitive. 'window' excludes
status bar; 'screen' is the full device extent. For Android edge-to-
edge content under the gesture nav, 'window' translate left a
sliver below the safe area = the 'little black box that waits'.
Result: from the user's POV, taps Receive → screen slides up, can pan
freely, swipes down → screen slides down, gone. No native back
animation, no empty container, no black flash, no sliver. Single
animation source per surface — receive owns its frame, BottomSheet
primitive owns its frame, identical curves and timings.
* fix(primitives,receive): clamp backdrop interpolation; route header X through animated dismiss
BottomSheet.tsx
Backdrop opacity interpolation now clamps. A fast flick can park
translateY past SCREEN_HEIGHT before the close timing settles; without
clamping, opacity extrapolates negative. Reanimated/RN tolerate it today
but the contract is undefined.
receive.tsx
Extract animateAndDismiss helper that runs the same withTiming(SCREEN_HEIGHT,
TIMING_CLOSE) → router.back sequence the gesture's onEnd already used. The
header X button now calls this helper instead of router.back() directly.
Without this, header-X dismiss triggered the route's animation:'none' on a
still-on-screen content view, producing a black flash. All non-gesture
dismiss paths now share one animation curve.
* feat(send): wire QR scan + Solana Pay URI parser to recipient picker
Replaces the placeholder "QR scan coming soon" alert with the existing
QRScannerModal already used by PeersDrawer and JoinGroupModal. Adds a
parseSolanaPayUri helper to src/services/solanaPayUri.ts as the single
source of truth for both bare base58 and full Solana Pay URI decoding
(amount, spl-token, label, message, memo, reference).
QRScannerModal's solana variant now carries the parsed params instead of
just the address, and the inline parsing is delegated to the new helper
so future consumers don't drift. RecipientPicker uses the params: on
scan, it sets the recipient address and — when an amount is requested
without an spl-token — pushes straight to the amount step with the
amount prefilled, so the user lands on the keypad ready to confirm.
spl-token requests are intentionally ignored for amount prefill: the
send flow is currently SOL-only (TokenPicker.isSendable filters all
SPL entries pending the legacy transferInstruction T22 fix), so we
honor the recipient but let the user choose the amount in SOL.
AmountKeypad reads the optional amount param and falls back to "0" so
manual continue from the recipient step is unchanged.
* feat(scanner): refresh camera permission on app foreground
When a user denies camera permission with "don't ask again", the only
path to grant is via OS Settings. expo-camera's useCameraPermissions
only re-checks on mount + on requestPermission(), so returning from
Settings with a fresh grant left the modal stuck on the denial UI
until the app was killed and reopened.
useCameraPermissions returns a third element — a silent getter that
reads OS state without showing the prompt. Subscribe to AppState while
the scanner is visible and call the getter on each foreground; grants
made in Settings now reflect live without a re-mount, denials stay
silent (no prompt loop), and there's no effect when the scanner is
closed.
* test(tier0): cover parseSolanaPayUri in services validator
Builder had fixture coverage but the new parser shipped against the
camera path with only ad-hoc fixtures. Adds testParseSolanaPayUri to
the existing tier-0 services validator (no new test infra) covering
the four areas reviewers flagged: bare base58, solana: URIs with and
without params, invalid inputs, and spl-token / reference filtering.
Also asserts the silent-drop policy on amount edge cases (zero,
negative, NaN, 10-decimal overflow) — recipient still surfaces so the
keypad picks up the address while the user enters the amount manually.
* fix(cosigns): mark multisig co-sign UI as preview, disable buttons (#39)
Co-sign signing flow is not yet wired but the panel previously rendered
a fully-styled "Sign with Biometrics" CTA with a lock icon whose
onPress simply removed the item from local state. That's theatre —
a tap looks like authorization but produces no signature, no tx, no
on-chain effect.
This neuters the surface without removing the roadmap signal:
- PendingCosigns: section labelled "MULTISIG CO-SIGNS" with a PREVIEW
badge; both action buttons inside each card are now non-interactive
(pointerEvents=none, dim opacity), the lock icon is removed, and the
primary CTA reads "Preview — not yet active".
- Empty state copy now says "Multisig co-signs not yet live" instead
of the misleading "No pending requests".
- Removed the now-unused onSign/onReject prop plumbing; the no-op
handlers in WalletScreen + the dead PendingCosigns import in
NodesScreen are deleted alongside.
Mock card data is intentionally retained so the panel still
communicates the intended UX of multisig once it's wired.
* fix(wallet): give activity tile real empty + error states (#43)
The wallet's recent-activity panel previously rendered both empty and
error states as a single dim TILE_LABEL line ("NO ACTIVITY YET" or
the raw error string). Demo posture is much better with explicit copy
and a recovery affordance.
Empty state:
- Title: "No transactions yet" (sentence case, friendlier than the
shouty TILE_LABEL).
- Subline: "Tap Receive above to share your address." Routes the
user toward the next obvious step instead of leaving them parked.
- Bird mascot retained.
Error state:
- Title: "Couldn't load activity"
- Subline shows the raw error message (preserved for diagnostics).
- New "Try again" button that calls onRefresh — same handler the
pull-to-refresh control uses, no new wiring.
- Wifi-off icon to make the failure mode read at a glance.
Also:
- useWalletBalance: catch-all error string upgraded from "Failed to
fetch balance" → "Couldn't refresh balance — pull to retry" so
the message tells the user how to recover, not just that it broke.
* fix(wallet): make balance-tile eye + refresh icons accessible (#42)
The eye-toggle and refresh-balance affordances on the wallet's balance
tile previously had:
- 13-14pt icons in colors.textTertiary (hard to see at a glance)
- hitSlop 8pt → ~30pt diameter touch target (under both Apple's 44pt
and Material's 48dp guidance)
- No accessibilityLabel / role / state — VoiceOver and TalkBack would
read both as a generic unlabelled control
This pass keeps the icons in their existing layout slot (avoids
colliding with header redesign work in flight) and just makes them
properly tappable and announced:
- Bump icon size 13/14 → 18 and color textTertiary → textSecondary
for visibility against the surface2 tile background
- Bump hitSlop 8 → 16 (target ≈50pt diameter)
- Add accessibilityRole "button", a purpose-driven accessibilityLabel
for each, and accessibilityState.checked for the eye toggle so
screen readers announce the balance-hidden state
- Disable the refresh button while loading and show pressed-opacity
feedback so the tap registers visibly even before the network
round-trip
- Bump the icon-row gap 12 → 16 so the larger hit zones don't
visually crowd the spinner
* refactor(tx-detail): migrate TxDetailModal to AppBottomSheet + fix primitive gesture/dim bugs (#41)
* refactor(tx-detail): migrate TxDetailModal to AppBottomSheet
The transaction-detail modal previously used the bespoke RN Modal +
dismissArea pattern that predates the AppBottomSheet primitive landed
in #34. The recipe in BottomSheet.tsx's docstring lists this surface
as a planned consumer; this PR makes the swap.
Behavior changes for users:
- Pull-to-dismiss gesture (1:1 finger tracking on the UI thread)
with velocity-based release.
- Backdrop dim now fades smoothly with drag progress instead of the
binary slide-in/out of the previous implementation.
- Apple-modal-curve open/close timing.
The modal's contents are unchanged: the same header with status pill,
the same six DetailRow entries, the same Explorer + Close action pair.
Existing inner ScrollView is dropped; AppBottomSheet's maxHeight 90%
is sufficient for the detail panel's natural height across all
real-world variants (memo + mint extras included).
This is the second consumer of AppBottomSheet after TokenPicker,
strengthening the primitive as the single source of truth for
sheet-style surfaces.
* fix(bottom-sheet): leading backdrop fade + more sensitive pan activation
Two bugs uncovered during Seeker smoke of the TxDetailModal migration:
1. Backdrop "slides up" with the sheet on open. Root cause: backdrop
opacity was interpolated directly from `translateY`, so the dim
cross-faded synchronously with the slide. Visually that reads as
"dim rising with the sheet" rather than the iOS-standard "sheet
rises onto already-dim background".
Fix: give the backdrop its own `backdropOpacity` shared value. On
open, animate it over 200ms with no delay (leading the 320ms sheet
slide). On close, animate both in lockstep over 220ms. During
interactive drag, the Pan's onUpdate mirrors translateY to
backdropOpacity so dismissal still feels coupled to the gesture.
2. Pan-from-body unresponsive. Root cause: `activeOffsetY(12)` is too
permissive a window for Pressable children to compete in via RN's
responder system. Press claims the touch, drags through the first
~10pt before Pressable cancels (movement > pressRectOffset), and by
then gesture-handler often can't pick up the in-progress drag
cleanly. No `activeOffsetX` constraint also meant diagonal flicks
could ambiguously route between Pan and child taps.
Fix: lower `activeOffsetY` 12 → 5 (claim before Pressable's cancel
window), add `activeOffsetX([-20, 20])` to constrain to vertical
motion, and add `shouldCancelWhenOutside(false)` so a drag that
crosses the sheet bounds during interaction doesn't get cancelled.
Affects every consumer of AppBottomSheet: TokenPicker (already on
upstream/v3 from PR #34) and TxDetailModal (new in this PR). No
behavior changes for callers — same props, same lifecycle.
* fix(bottom-sheet): kill snap-back jitter — timing curve instead of spring
Snap-back after a partial drag previously used a spring (damping 22,
stiffness 320). That spring is mathematically underdamped — damping
ratio ≈ 0.61 against critical 35.78 — so the system overshoots the
rest position and oscillates on settle. After short drags (e.g. user
pulls down 20pt then releases without dismissing) there's no
perceptual cover for the wobble, and it reads as jitter.
Replace with a deterministic timing curve (260ms ease-out exponential)
for both translateY and backdropOpacity. No overshoot, no oscillation,
no visible jitter on settle. This is what production sheet libraries
(gorhom/bottom-sheet, etc.) use for snap-back for exactly this reason.
Affects every consumer of AppBottomSheet — TokenPicker and
TxDetailModal (this PR). Same props, same lifecycle.
* fix(beacon): neuter fake biometric CTAs (preserves roadmap chrome) (#40)
* fix(beacon): strip fake yield/rep numbers and disable biometric CTAs
The Beacon Registry surface previously rendered a polished "Become a
Beacon → Sign with Biometrics" flow whose onPress just flipped the
LXMF beacon-mode flag — no biometric prompt, no signature, no
on-chain stake. The active state then displayed hardcoded yields,
rep scores, JitoSOL conversions, and a "0.000312 SOL EARNED" hero
that was a literal constant. The Stake modal repeated the lie:
"Sign with Biometrics" → onPress=dismissStake.
This kills the theatre while keeping the roadmap signal:
- Strip JITO_RATE / JITO_APY / earned / cosigns constants and the
derived jitoAmt / yieldAmt / repScore / newRep / newYield values.
- Active hero now shows reachable-node count (real, derived from
beacons + online peers) under a NODES REACHABLE label, plus an
inline preview notice explaining staking and reputation are not
yet wired.
- Become-a-Beacon modal: CTA now reads "Preview — not yet active"
with pointerEvents disabled and reduced opacity. Lock icon
removed. Beacon mode still auto-activates via the existing
hasInternet effect, so no functional regression.
- Stake modal: title gets a PREVIEW badge; impact rows replaced with
a copy block explaining stake delegation is not yet wired; CTA
neutered identically to Become-a-Beacon.
Mock card data and the visual stepper are intentionally retained as
preview chrome so the planned UX still reads.
* revise(beacon): preserve roadmap chrome, only neuter modal CTAs
Walking back the aggressive strip from the previous commit. The
JITO_RATE / APY / REP SCORE / SOL EARNED hero is intentional roadmap
signaling from the team lead — the visual chrome communicates "this
is what beacon staking will look like" and that signal has product
value, not a credibility cost.
What restores:
- Active hero: SOL EARNED, REP SCORE, JITOSOL, SOL/YR row, co-signs
+ reachable footer all return as designed.
- JITO_RATE / JITO_APY / cosigns / earned constants restored.
- "Stake More SOL" modal title restored (no PREVIEW badge needed at
this level).
- Stake stepper impact rows restored (rep / yield before-after).
What stays neutered:
- Become-a-Beacon modal CTA: replaces lock-icon + "Sign with
Biometrics" with a disabled "Preview — not yet active" pill.
Beacon mode still auto-activates via the existing hasInternet
effect, so the modal can still demo the staking UX without
triggering a fake biometric handler.
- Stake modal CTA: same neutering.
The single credibility risk we still address is the lock-icon-with-
fake-onPress that judges would tap and find unwired. Everything else
is preserved.
Net diff vs upstream/v3: 12+ / 12-.
* feat(ui): pigeon-loader sending screen + send-flow wire-up (#44)
* feat(ui): add PigeonLoader + dev preview route
Phantom-style full-screen loading overlay for transactional flows.
Uses animated WebP via the already-installed expo-image (no new deps).
- assets/animations/sending.webp — 240x240 q70 animated WebP, ~350KB
- components/ui/PigeonLoader.tsx — Modal overlay with optional cancel
- app/dev/pigeon-loader.tsx — preview route for testing without a real tx
Reachable for QA via: anonmesh://dev/pigeon-loader
Component is unwired; send-flow integration will follow in a separate PR
to keep this lane-clean against components/send/.
* fix(ui): PigeonLoader — kill WebP ghosting + bump render size
Device review showed two issues:
1. Ghost frames stuck on screen during loop. Root cause: ffmpeg's webp
encoder writes frames with dispose=none + blend=alpha, so each frame
composites onto the previous. Sparse transparent regions left
trailing pixels.
Fix: pre-composite every source frame onto a solid #00080c (theme
background) via Pillow, then save without alpha. No alpha plane =
every frame is fully opaque = ghosting is impossible.
Side effect: 358KB -> 292KB (smaller, no alpha plane).
2. Loader felt too small. Image render 160 -> 220 (≈37% bigger).
Source resolution bumped 240 -> 280 to keep sharpness at the
larger render size on 3x devices.
3. Backdrop opacity dropped from 92% to fully opaque, matching the
Phantom-wallet pattern for blocking loaders. Also avoids the
visible-rectangle artifact that would appear if a fully-opaque
sprite sat on a translucent backdrop of a slightly different
blended color.
* feat(ui): PigeonLoader — mesh bg, pulse, haptics, success state
Elevate the send loader from a generic spinner into a brand moment that
tells the AnonMesh routing story while a tx is in flight.
Changes:
- Bigger hero — pigeon render 220 -> 280pt (fills the central area)
- Larger label (24pt semibold), sublabel hidden by default — less text
- Subtle pulse on the pigeon (1.0 ↔ 1.03 / 1.8s sine) so the image
doesn't sit still during the WebP's quieter frames
- Ambient mesh background — 12 drifting cyan nodes + 19 connecting
lines on the brand primary color, driven by a single shared phase
via Reanimated worklets. Renders behind the pigeon at low opacity.
Tells the "your tx is hopping across peers" story visually.
- New success state (`status: 'loading' | 'success'`):
pigeon crossfades out, Feather check springs in with a brand-green
glow ring. Fires the success notification haptic.
- Mount haptic — medium impact when the loader becomes visible,
announcing the blocking state.
Asset:
- assets/animations/sending.webp re-encoded with alpha so the mesh BG
reads through the transparent regions around the pigeon. Avoids
ghosting via explicit per-frame disposal=2 (background) in the
WebP encode (Pillow), so each frame fully replaces the previous.
320x320 q=70 alpha_q=80 → 614KB.
Dev route:
- "Full lifecycle" buttons play sending → success → dismiss so the
whole sequence can be felt without a real tx
- Manual status toggle exposes the loading↔success transition
- Sublabel and cancel button toggles still present
Deps: zero new packages. Uses expo-image, expo-haptics,
react-native-reanimated, react-native-svg — all already installed.
* refactor(ui): PigeonLoader — logo-derived mesh + SVG success ring
Two device-review fixes:
1. Mesh BG redrawn from the actual AnonMesh logo geometry.
- Node positions extracted from assets/icons/anonmesh_white_icon.png
via Pillow distance-transform (6 nodes, 10 edges).
- Logo scaled 1.15× the screen, centered. The pigeon mounts in
front of the central node, exactly like the brand icon's core.
- Static dots dropped in favor of stable topology that matches the
logo silhouette.
- One bright "message" dot per edge, all driven by a single
11-second shared phase. Each message travels half the cycle
(visible) then fades out for the other half. Phases staggered
so messages don't all arrive simultaneously.
- Color cohered to brand primary cyan at three opacity tiers:
edges 15%, nodes 20%, traveling messages 85%.
2. Success ring rebuilt in SVG to kill the octagon artifact.
The previous View used borderRadius=48 + borderWidth=2.5 +
elevation=10. On Android, large-radius rounded views with
elevation shadows render with quantized polygon edges — that
was the "incomplete shape inside the circle" symptom.
Now: <Svg> with three concentric Circles (outer halo, mid halo,
ring stroke) for guaranteed anti-aliased curvature and a soft
glow that doesn't rely on platform shadow clipping.
* perf(ui): MeshBackground — align center node, GPU-composite messages
Three improvements rolled together; the perf one matters most.
1. Center node aligned to screen center.
The logo's central node sits at (0.596, 0.417) in source coords, not
(0.5, 0.5). Previously the bounding box was centered, which made the
visual center sit right-of-center on screen. New positioning anchors
that single node to (width/2, height/2). The pigeon now flies over
the logo's actual core, and the whole pattern reads as the brand mark
instead of a generic web.
2. Scaled 1.15× → 1.45×.
Outer nodes intentionally drift past the screen edges. Pattern reads
as ambient / abstract rather than "here is a small diagram." Static
node/edge opacities lowered to compensate (14% / 12%) so the mesh
stays subordinate to the pigeon.
3. Animated messages moved off react-native-svg onto Animated.View
transforms. SVG <Circle> with useAnimatedProps was triggering native
tree updates each frame across 10 elements — that's where the perf
cost was hiding. Each message is now a positioned Animated.View
driven by useAnimatedStyle, so the transform updates run UI-thread
only and never round-trip to RN's view manager.
Side benefits from the rewrite:
- Smoothstep easing on the position so dots accelerate out of one
node and decelerate into the next. Reads as more deliberate motion.
- Three-stage opacity curve: fade in over 18% of the visible window,
hold at peak, fade out over the last 18%. No more abrupt blink-on
/ blink-off at edge endpoints.
- Stacked core + halo (one bright inner, one softer outer) for a
glow without relying on platform shadow primitives.
- Visible window dropped to 42% per cycle, travel period 11s → 7s.
Net result: fewer dots on screen at once but each one is brighter
and faster, which reads as more pronounced motion without crowding.
Static layer (nodes + edges) still SVG — drawn once, zero per-frame
cost.
* revert(ui): drop MeshBackground from PigeonLoader
Mesh BG didn't add enough to justify the visual + perf cost on top of
the pigeon. Loader is now just the dark backdrop, pigeon, and label.
Cleaner base to layer alternative polish on.
* feat(ui): PigeonLoader — spotlight, label slide, success shockwave
Three polish layers replacing the dropped mesh BG. None of them add
motion that competes with the pigeon during normal loading.
1. Radial spotlight backdrop.
Static SVG <RadialGradient> centered on screen — cyan primary at
~11% opacity in the core, fading to transparent at 100%. The void
backdrop now reads as an intentional stage rather than dead black.
Zero motion, single-render, no per-frame cost.
2. SlideLabel — animated label transitions.
When the label text changes (Sending → Sent on success, or any
other override), the outgoing text eases up and fades out while the
new text rises up and fades in. State change feels announced
instead of just swapped. The transition is keyed on text equality
so identical re-renders don't trigger it.
3. Success shockwave.
Single ring positioned at…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
setPeerscalls per batchsetPeers/setNameMapinstartTransition— peer-list renders are interruptible, JS thread drains incoming events fasterprunePeerMapfrom the hot event path to a 10ssetIntervalvia/onlinestatus:bleActiveRefprevents stale closure race at startup;blePeerCount > 0effect re-tags 0-hop peers asvia='ble'and marks them online immediately instead of waiting up to 60s for next announceNavigationBar.setBackgroundColorAsyncon Android — no-op with edge-to-edge; was emitting aconsole.warnon every theme changeTest plan
WARN setBackgroundColorAsyncshould no longer appear