fix(cosigns): mark multisig co-sign UI as preview, disable buttons#39
Merged
epicexcelsior merged 1 commit intoMay 11, 2026
Merged
Conversation
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.
6 tasks
Magicred-1
pushed a commit
that referenced
this pull request
May 16, 2026
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.
Magicred-1
added a commit
that referenced
this pull request
May 16, 2026
* 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 screen center, hidden until…
Magicred-1
added a commit
that referenced
this pull request
May 17, 2026
* 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 screen center, hidden unti…
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
The co-sign signing flow isn't wired yet, but the panel previously rendered a polished "Sign with Biometrics" CTA with a lock icon whose
onPressonly removed the item from local React state. A judge tapping that button at the booth gets no signature, no transaction, no on-chain effect — and that's a credibility risk we can't afford the day before the gate.This PR neuters the surface without removing the roadmap signal.
Changes
components/nodes/PendingCosigns.tsx— section now reads "MULTISIG CO-SIGNS" with a small PREVIEW badge. Both action buttons inside each card are wrapped inpointerEvents="none"with reduced opacity. The lock icon is removed and the primary CTA reads "Preview — not yet active". Empty-state copy updated to "Multisig co-signs not yet live".screens/WalletScreen.tsx— drops the no-ophandleSign/handleRejectsince the panel is non-interactive; mock card data retained as preview chrome.screens/NodesScreen.tsx— removes dead code: `PendingCosigns` was imported and the state was set but the component was never actually mounted on this screen.Net diff: +21 / -51.
Test plan