Skip to content

Commit 46406f8

Browse files
Fix/lxmf v0.2.73 integration (#98)
* build(babel): babel.config.js + explicit class-static-block plugin (#86) Adds babel.config.js enabling @babel/plugin-transform-class-static-block (required by three.js in the mesh-map renderer) and declares the plugin as a direct devDependency so resolution is guaranteed on fresh installs. Fixes the silent iOS bundling failure (Metro HTTP 500) that left dev-clients running stale embedded JS. * fix(qr-scan): gate CameraView on permission + surface unrecognized QR Two related iOS bugs observed on a personal-team dev build: 1. CameraView mounted before permission resolved → black preview that never recovers until app restart. On iOS the component caches its permission state at mount; flipping null→granted at runtime doesn't re-init the preview. Gate the mount on `permission.granted === true` so the conditional flip causes a fresh mount with permission in place. 2. PeersDrawer's QR onResult silently dropped any QR that wasn't a plain LXMF hash — group QRs, Solana addresses, malformed/empty payloads all closed the modal with no feedback, leaving users thinking "scanner is broken." Surface explicit alerts: - lxmf-group → "looks like a channel QR, use Join channel" - solana → "wallet address, use Send screen" - unknown → show first 64 chars of raw payload so user can debug Also adds a __DEV__ console.log of the parsed result so we can adb logcat-trace exactly what came out of the scanner. * build(deps): add expo-audio as expo-camera 17 peer dep expo-camera@17 split audio recording into a separate package (expo-audio). iOS native side hard-links the ExpoAudio module even when the JS-level use is barcode-scanning only, so the dev-client build fails with "Cannot find native module expoAudio" without the peer dep installed. `npx expo install expo-audio` added 1.1.1 to deps and registered the config plugin in app.json. No source code changes — this is pure native bridge availability. * refactor(scanner): present QR scanner as a route, not a nested modal QRScannerModal was a free-floating <Modal>. Rendered inside the join-channel sheet (itself a <Modal>) it stacked two native iOS windows — black camera preview, and corrupted touch handling on dismiss (frozen screen, camera never released). A single modal (Send, peers drawer) worked; only nesting broke. - app/scan.tsx route + scan() promise API (src/services/qrScan), usable identically from any screen or modal; one camera/permission lifecycle; re-entrancy-guarded so overlapping calls can't stack routes. - Convert the join-channel sheet to app/join-channel.tsx (transparentModal owning its slide, like receive) so the scanner composes over it natively — no nested-modal conflict. - Update RecipientPicker, PeersDrawer, and the join flow to await scan(). Delete QRScannerModal and JoinGroupModal. * test(tier0): align solanaPayUri label/message expectations with shipped lowercase default * fix(lxmf): surface silent identity-persist failures and fix peer-history undershoot L3: a secureSet rejection when persisting the LXMF identity was swallowed in a console.warn. A failed write can spawn a fresh identity on next cold start — losing the mesh address and message continuity — with no user-visible signal. Route it to the existing LxmfErrorBanner via a new identityError state, cleared on a later successful persist. MSG-2: getPeerMessages fetched a fixed most-recent window (limit*2) then filtered to the peer, silently returning too few messages for a peer whose history sits deeper than that window. Grow the window until limit matches are found or a short page proves the store is drained. * feat(dev): guard app/dev/* routes behind __DEV__ Expo Router auto-registers every file in app/ as a deep-linkable route, so a production binary could open anonmesh://dev/* developer screens. Add a dev-group layout that redirects to the app shell unless __DEV__, keeping dev tools out of release builds. * fix(lxmf): gate dev local-PC hub behind __DEV__, not just an env var MY_PC (a developer's local Reticulum hub) was derived solely from EXPO_PUBLIC_LOCAL_LXMF_HOST and unshifted into the node's interface list in every build. EXPO_PUBLIC_* values inline into the production bundle when present in the build env, so a leaked var could wire a dev machine as a hub in a release binary. Gate on __DEV__ per project conventions (use __DEV__, not EXPO_PUBLIC_*, for dev-only code). * feat(dev): tutorial reset + dev menu for rehearsing first-run flows Adds resetTutorial() (clears the tutorial-completed flag) and an app/dev index screen that lists dev screens and exposes reset + replay actions, so onboarding and the tutorial can be rehearsed repeatedly without reinstalling. Lives under the __DEV__-guarded dev route group, so it never ships to production. * fix(scripts): use a portable shebang for lxmf_send.py The shebang hardcoded a teammate's pipx venv path (/home/m4gicred1/...), so the LXMF send helper only ran on one machine. Use /usr/bin/env python3. * fix(beacon): label beacon economic stats as a preview, not live earnings The active BeaconRegistry card presented hardcoded figures — 0.000312 SOL 'earned', 24 co-signs, and derived rep/jitoSOL/yield — as if real, but the co-sign and staking economy is future work (see the in-file note, and the stake modal already reads 'not yet active'). Add a PREVIEW pill and a disclaimer so the illustrative figures no longer masquerade as real earnings; the reachable-peer count stays real. Exact copy/placement pending on-device visual confirmation. * test(lxmf): extract + unit-test the peer-history window algorithm (MSG-2) Pulls the adaptive-window logic out of getPeerMessages into a pure collectPeerMessages() and adds tier0 assertions — including the deep-peer case the old fixed limit*2 window silently dropped (old: 0 found, new: all 5). getPeerMessages now delegates to the tested helper. * fix(lxmf): stop getGroupMembers triggering setState during render getGroupMembers runs inside a MessagesScreen useMemo (during render) and called lxmf.getStatus(), which refreshes LxmfProvider state -> 'Cannot update a component while rendering a different component' warning. Read the cached lxmf.status instead (identical for our own addressHex). Pre-existing bug, surfaced by on-device testing of the messages thread. * perf(lxmf): single bounded fetch in getPeerMessages (revert escalating loop) The adaptive-window loop re-fetched and re-scanned the whole message store on every conversation open (400->800->1600->... until drained), making threads take seconds to open on a busy mesh. Revert to a single most-recent-window fetch (limit*2). The deep-peer undershoot it tried to address needs a native per-peer query (catalogued), not a JS loop. Test updated to the bounded contract. * refactor(ui): drop dead Expo-template theme island, add shared state primitives Remove the unused starter-template stack (themed-text/view, parallax scroll view, hello-wave, external-link, collapsible, icon-symbol, constants/theme, use-theme-color, use-color-scheme) — it was a second, competing color/font source with zero real importers. Add EmptyState / ErrorState / LoadingState to components/ui as the canonical, token-driven blocks for the app's empty / error / loading moments. * refactor(onboarding): route hardcoded styling through design tokens Tokenize colors, type sizes, radii and exact-match spacing on the onboarding and tutorial surface. Brand/shader/gradient colors and bespoke loading choreography left intact. No behavior change. * refactor(messages): tokenize styling + use shared EmptyState Route hex/type/radii/spacing through tokens across the messages tab and component set; replace the group-members empty block with the shared EmptyState primitive (copy/behavior preserved). Chat-bubble accent colors and camera/lightbox chrome kept intentional. No behavior change. * refactor(wallet): tokenize wallet + home-dashboard styling Route colors/type/radii/spacing through design tokens across the wallet tab, panels, tx detail, and home cards. Per-asset brand colors (SOL/USDC/etc.), tx-direction red/green, and on-primary button ink kept intentional. Wallet, balance, and transaction logic untouched. * refactor(nodes): tokenize peers/mesh styling Tokenize hex/type/radii/spacing on the Peers tab. Mesh-map visualization palette (per-handle node colors, signal-link tints) and sub-token canvas labels kept intentional. Cosign/transaction logic untouched. * refactor(settings): tokenize settings + contacts/receive styling Route colors/type/radii/spacing through tokens across settings, the security modals, contacts and receive; fullscreen modal scrims now use colors.overlay; contacts empty block uses the shared EmptyState primitive (copy preserved). QR-rendering colors kept literal for scan contrast. Auth/biometric/key logic untouched. * refactor(send): tokenize send-flow styling Tokenize hex/type/radii/spacing across the send recipient/amount/review/result components. Oversized amount-display numerals and derived error-alpha borders kept intentional. Transaction-build, validation and navigation logic untouched. * refactor(ui): tokenize shared chrome + primitive nits Tab bar, root error banner, and a few shared primitives: swap only already-token-equal values (zero visual change) plus normal tokenization of the non-reused chrome. Off-scale primitive dimensions and #000 shadows left deliberately to avoid app-wide ripple. * feat(observability): Sentry crash-only telemetry (ErrorBoundary, PII scrubber, DSN kill-switch) src/observability/{sentry,scrub,ErrorBoundary} + scrub tests; ErrorBoundary wired in _layout; metro/app.json/deps for Sentry. Crash-only (no product analytics), DSN gated via env kill-switch, PII scrubbed. * feat(crash-safety): add ErrorBoundary + global async error handler Any render/lifecycle throw in the provider tree now shows a recovery screen ("Something went wrong / Try again") instead of white-screening the app. Hard-coded dark colors so the fallback renders even if ThemeProvider itself threw. Global ErrorUtils handler logs unhandled async throws without swallowing them; preserves the previous handler in __DEV__ so the dev overlay still works. * fix(wallet): remove hardcoded pending co-sign array The three placeholder PendingCosign entries (0.25 / 1.05 / 0.005 SOL) were rendered as if they were live requests. Replaced with an empty array so the component falls through to its existing honest empty state ("Multisig co-signs not yet live") until the real data source is wired. * fix(beacon): zero fake stats, fix PREVIEW pill, disable stake chip (QA-55) - cosigns/earned: drop useState(24)/useState(0.000312); both are 0 until the co-sign feed is live. Shown as '—' under the PREVIEW label. - jitoAmt/yieldAmt/repScore: removed JITO_RATE, JITO_APY, STAKE_NUM constants; all three now display '—' (preview placeholder). - PREVIEW pill: was only shown when beacon is active; now always visible so inactive-state users know it's a preview feature too. - Stake chip: was a Pressable opening a staking modal; disabled to a greyed-out "Soon" view since no real stake transaction exists. - "Register as Beacon" description: removed false "Stake 0.5 SOL — fully returned on exit" claim; fee list now lists relay (live) vs staking/earnings (coming soon) honestly. - QA-55: reachableCount no longer double-counts beacon-peers (beacons are already merged into peers by LxmfContext.mergeBeacon). * fix(ux): network-aware explorer URL, drop devnet copy in contacts contacts.tsx:216 — "trusted devnet address" → "recipient address": network-neutral, doesn't hint at test-network framing to users. explorer.ts — renamed buildDevnetExplorerTxUrl → buildExplorerTxUrl; function now reads EXPO_PUBLIC_SOLANA_RPC to derive the cluster param so explorer links are correct when pointed at mainnet/testnet. Old name kept as a deprecated re-export so callers compile without change. * fix(mesh): throw typed error on malformed beacon RPC response A truncated or non-JSON beacon relay body made JSON.parse in MeshRpcAdapter.rpc throw a raw SyntaxError that bubbled up as an app crash. Wrap the parse and throw a typed MeshResponseParseError so confirmation polling and balance fetches treat it as a normal transport failure and recover instead of hard-crashing the send flow. * fix(send): bound every direct-RPC await with withTimeout The fee-estimate path already wrapped its RPC calls in withTimeout, but the signing, submit, confirmation-poll, and balance/activity reads did not. On a degraded RPC that accepts the socket but never answers, those bare awaits parked forever: the confirm loop could not re-check its deadline mid-await and the user got a permanent spinner with no recovery. Wrap all of them (sendRawTransaction, getLatestBlockhash on the submit path, getSignatureStatus in the poll, getParsedTokenAccountsByOwner, getSignaturesForAddress, getParsedTransactions) in withTimeout at the existing DIRECT_RPC_TIMEOUT_MS. Each now rejects on a hang so callers surface an error (or, in the poll, retry next cycle) instead of freezing. Exported DIRECT_RPC_TIMEOUT_MS so walletData shares the one constant. * fix(wallet): drop stale balance fetch on adapter swap; bound getBalance A mesh<->online adapter switch (or wallet change) re-runs refetch while a previous Promise.allSettled is still in flight. The slower stale fetch could resolve last and overwrite fresh balances/activity with results from the now-dead transport. Stamp a monotonic fetch id at the start of each refetch and only let the latest one commit to state (including the loading flags), so a superseded fetch is discarded. Also wrap the one remaining bare RPC call here (getBalance) in withTimeout so a degraded RPC cannot park the whole refresh; matches the bound now applied to every other RPC site. * fix(wallet): never delete a wallet on a transient keychain read error (QA-20) secureGet() swallows all SecureStore errors and returns null, so a real keychain read failure (transient lock, access-group mismatch after a signing-cert change) was indistinguishable from "key never stored". isFullyIntact() collapsed that to a bare false, and hasLocalWallet() / createLocal() then DELETED the wallet — wiping a real, funded wallet over a momentary glitch. Add secureGetStrict() which throws on a read failure and returns null only for verified absence. isFullyIntact() now reads strictly and returns a discriminated { intact | absent | error } result. hasLocalWallet() and createLocal() only delete/recreate on a verified 'absent'; an 'error' is treated as "wallet present, integrity unknown" and surfaced to the unlock path so the user can retry instead of losing keys. readAndDecrypt() reuses secureGetStrict() in place of its duplicated inline try/catch. * fix(send): repair review failure-recovery, cancel, and address save Four coupled fixes in the confirm flow: - Cancel during the confirm poll now aborts the in-flight controller. Previously it only reset local UI state, so the poll kept running and a late status read could still navigate to Success after the user backed out. Also guards the confirmed branch against the abort-vs-resolve race. - The failed branch now resets isConfirming/txPhase/slider before pushing the failure screen. FailureCard's Try again does router.back() onto this still-mounted Review; without the reset it landed on a frozen loader with no slider, making retry unreachable. - The cancelled branch resets state too (was a no-op), so an aborted poll leaves a clean Review instead of a stuck loader. - saveAddressBookRecipient moved into the confirmed branch only. Saving on submit let a failed tx or poisoned address leak into the address book and resurface as a suggestion. - Loader sublabel is network-aware: only claims 'devnet' in online mode (the hard-wired devnet RPC); neutral 'Confirming' over mesh, whose relay cluster the screen cannot assert. * fix(wallet): zero transient seed/AES buffers after wallet create (QA-54) create() left the raw 32-byte seed and AES key sitting in the JS heap after they were persisted. Both are fully consumed by then (seed by Keypair.fromSeed, AES key by the encrypt + base64 persist) and never read again, so wipe them with fill(0) to shrink the window where raw key material is recoverable from memory. keypair.secretKey is left intact — the returned wallet still needs it to sign. * fix(messages): harden inbound req-pay/req-addr against malformed peer payloads A peer fully controls the JSON body of req-pay/req-addr/share-addr. The request bubbles render note/asset/amount straight as React children, so a non-string value (e.g. an object) crashes the renderer and white-screens the app (no error boundary above MessagesScreen). Type-guard at the parse boundary: validate asset against a known ticker allow-list, coerce note to a clamped string (drop non-strings), require a finite non-negative amount, and require a bounded string address. Mirror the guard inside RequestMoneyBubble/RequestAddressBubble as a render-time safety net so no future caller can reach <Text> with an unsafe value. * fix(wallet): wipe exported private key from clipboard (QA-23) Copying the recovery key left the raw base58 private key on the system clipboard indefinitely; the 1.4s timer only reset the "copied" badge. The clipboard persists and on some OSes syncs cross-device, so the key leaked well past the export flow. Wipe it on three edges, mirroring the channel-key auto-clear already used in ChannelShareSheet/CreateGroupModal: a 60s auto-clear timer reset on each copy, an immediate wipe on dismiss, and a best-effort wipe on unmount. The pending timer is cleared on unmount so no setState/clipboard write fires after teardown. The copy UX is unchanged. * fix(identity): always authenticate before keypair rotation (QA-19) confirmRotate() wrapped the re-auth in `if (biometricEnabled)`, so with the biometric toggle off the IRREVERSIBLE rotation (wipes the ed25519 keypair + LXMF address) ran with zero authentication from any unlocked session. Authentication is now always required. When biometrics are off or unenrolled we force the OS passcode prompt (disableDeviceFallback: false), matching how onboarding's wallet-create authenticates; the toggle now only chooses biometric-only vs passcode-allowed, never whether to auth at all. A failed or cancelled auth aborts the rotation. * fix(messages): tell the truth in the no-peers empty state NoPeersScreen showed an animated 'SCANNING FOR PEERS / will appear automatically' sonar regardless of actual state — even offline or with Bluetooth denied, when nothing is being scanned. Branch on the real state (isRunning && bleActive): genuinely scanning keeps the sonar plus a reassuring 'no one nearby yet'; offline/BLE-off gets a static icon, a 'You're offline' heading, and an actionable 'Enable Bluetooth to find people nearby' instead of a false promise. Park and hide the rings entirely when not scanning so they don't read as a passive radar. * fix(messages): stop silently dropping pay-request / share-address actions The action-grid send was fire-and-forget: no active-peer check, no node-up guard, the send() promise unawaited, and no delivery state — so tapping 'request 2 SOL' with the node down showed the bubble as if it had sent. Mirror sendMsg: guard on a selected peer and a running node (surfacing a system line otherwise), await the send, and track the returned seq so a queued or failed request shows up in the queue banner like any message. * fix(messages): validate deep-link and notification dest hashes as hex paramDestHash (deep link) and the pending-conversation hash (notification tap) were routed on without shape-checking. A malformed value is a mis-route / notification-suppression vector — it can falsely equal, or never equal, the active peer's hash and either open a junk thread or swallow a real alert. Require a well-formed 16-byte LXMF hash (32 lowercase hex chars, the native canonical form) before routing. Consume the pending hash even when invalid so junk can't linger and re-fire on the next focus. * fix(crash-safety): always chain to previous global error handler Gating the previous-handler call on __DEV__ swallowed fatal errors in a release build — React Native's native fatal path (crash dialog + process teardown) never ran, leaving the app limping with no crash signal. Always chain to the previous handler so production crashes are not silenced. * fix(messages): harden ShareAddressBubble, tighten amount parse, guard grid send - ShareAddressBubble rendered peer-controlled asset/address verbatim; coerce them like the other request bubbles so a malformed share-addr can't crash the renderer (the QA-18 boundary covered the other two bubbles, not this one). - safeAmount accepted '' (Number->0) and '0x10' (->16); require plain decimal so a peer can't render a misleading or empty amount. - a grid-action send() throw could surface as an unhandled rejection; treat it as a failed send instead. * fix(send): never blind-retry on submit timeout (double-send guard) submitSignedTransaction wrapped sendRawTransaction in withTimeout, but that call only broadcasts — it does not confirm. On a slow RPC it could time out at 10s after the RPC had already accepted and forwarded the tx to the cluster. The TimeoutError rethrew as a generic submission error, ReviewCard's catch surfaced an inline Try again, and a retry re-signed with a fresh blockhash — landing a SECOND transfer. The existing double-send guard only covered the confirm path, not submit. A submit TimeoutError means the broadcast outcome is UNKNOWN, not failed. The signature is deterministic from the already-signed tx, so return it (base58, exactly what sendRawTransaction returns on success) and let the caller's confirmation poll establish the real outcome: confirmed -> success, or the confirm budget elapses -> timeout failure screen with Try again hidden and Explorer primary. A genuine RPC rejection (responded with an error -> tx not accepted) still rethrows and stays inline-retryable. A pre-sign getLatestBlockhash timeout never broadcast, so it is unaffected. * fix(wallet): make stale-fetch guard survive the refetch cooldown The fetchId bump sat after the COOLDOWN_MS early-return, so an adapter swap within the 30s window could re-enter refetch, bail at the cooldown gate without bumping the generation, and leave an in-flight fetch on the now-stale adapter holding the latest id — it then passed isCurrent() and committed dead-transport data, defeating the QA-52 guard. Snapshot the generation before the cooldown gate and invalidate it on adapter/publicKey change via a dedicated effect, so a fetch started on a previous transport can never win isCurrent(), even when the new adapter is still inside its cooldown and no fresh fetch starts. * fix(lxmf): treat shrinking event log as reset, not all-new (QA-25) A transport reset clears the LXMF event log. With the old guard, a shrunk log fell through to the head-moved branch and returned the entire array as new events, flooding duplicate notifications. Return [] on shrink so the smaller log is a fresh baseline. * fix(lxmf): clear activeConversationRef on resetIdentity (QA-26) activeConversationRef held the previous identity's peer hash across a rotate. The first incoming message on the new identity matched the stale ref, so useMessageNotifications classified it as the active thread and suppressed its notification. Null the ref in resetIdentity. * fix(lxmf): pause UI poll loops in background + clear debounce timers on unmount (QA-07, QA-15) QA-07: the 1 Hz BLE peer-count poll and the 10 s peer-prune poll ran forever, even backgrounded, draining battery with no UI to update. Gate both on an AppState foreground flag (same pattern as useMessageNotifications) so they pause on background and resume on foreground. The native foreground service is untouched — only the JS UI polls pause. QA-15: announceTimerRef and storageTimerRef are tracked on refs that outlive the event effect scheduling them, so the effect's own cleanup never cleared them — on provider unmount they could fire setIsAnnouncing / prefSetJson after teardown. Clear both in an unmount cleanup. * fix(wallet): allow device passcode fallback when creating local wallet PIN-only devices (no enrolled biometric) previously hit a silent failure: LocalWallet.create() ran authenticateAsync with disableDeviceFallback:true, so with no biometric there was no way to satisfy the prompt and creation threw 'Authentication cancelled'. Allow the device passcode as a fallback so these users can create a wallet. This is now the single auth prompt for the create flow. * fix(notifications): defer permission prompt until after onboarding The notification permission was requested at cold mount from the always-on NotificationBridge, so a brand-new user got an OS permission dialog before they had created a wallet or understood the app. Gate the request on having a connected wallet (onboarding complete) and notifications enabled, and fire it at most once per session. * fix(settings): confirm before disconnect + label dead cellular toggle Disconnect previously wiped the session in one tap with no warning. It does not erase the on-device key (you can sign back in with biometrics/passcode), but the wallet is unrecoverable if the device is lost or the app is reinstalled without a recovery-key backup. Add an explicit confirmation that states this honestly and offers 'Export key first' (opens the existing recovery-key modal). MWA gets a simpler confirm since its keys live in the Seed Vault. Also wrap the 'cellular fallback' toggle in the existing PreviewedActions shield: it was a live-looking switch wired to nothing (local state only), which reads as broken. Now labelled 'not yet active' like the other unbuilt controls; removed the dead meshOnCell state. * fix(onboarding): single auth prompt, recovery-key backup on create, no panel flash Three cold-start trust fixes on the onboarding screen: - Single biometric prompt: handleCreate prompted with authenticateAsync and then LocalWallet.create() prompted again — a double prompt, and on PIN-only devices the second (biometric-only) prompt failed silently and the button looked dead. Drop the redundant prompt; LocalWallet.create() owns auth. - Backup at creation: after a fresh local wallet is created, offer the recovery-key export (reusing ExportWalletModal) with one honest line that the key is device-local, before the user reaches the app. Non-blocking — 'Later' continues straight through. Skipped for MWA (Seed Vault). - Panel flash (QA-13): returning users whose wallet auto-restores saw the 'Join the Mesh' panel flash on every launch before the redirect. Gate the panel until wallet hydration settles (initialize() always cycles isLoading true->false on mount, so the hidden window is exactly the old flash window). * fix(android): inject android.os.Build import in content-capture plugin The injected CONTENT_CAPTURE_BLOCK uses Build.VERSION, but the plugin only imported android.view.View — a clean `expo prebuild` produced MainActivity.kt with an unresolved 'Build' reference and failed the Kotlin compile. Inject the Build import too, guarded so a template that already has it isn't doubled. * refactor(send): extract withTimeout to a dep-free util (fix tier0 validator) walletData.ts imported withTimeout/DIRECT_RPC_TIMEOUT_MS from sendTransaction.ts as a VALUE import via the @/ alias. The raw-node tier0 services validator loads walletData.ts and cannot resolve @/, and that import also dragged sendTransaction's heavy @/ graph (polyfills, storage, ...) into the validator -> ERR_MODULE_NOT_FOUND. Move the timeout primitive to src/utils/withTimeout.ts (dependency-free); walletData imports it via a relative .ts path (same style as ./errors.ts). sendTransaction re-exports for the existing call sites. tsc + tier0 services both green. * style(send): hoist withTimeout import to the top import block Clears the import/first lint warning from the prior extract commit; the timeout primitive import now sits with the other imports and is re-exported below for ReviewCard / useWalletBalance. * fix(nodes): surface denied Bluetooth permission with a recovery path enableBle() silently returned when BLE permission was denied, so peer discovery died forever with no explanation — and the empty state kept lying "Starting mesh…". Capture the permission status; when denied (or never-ask-again) show a card explaining Bluetooth is needed to find nearby devices, with a re-grant button (or Open Settings for never-ask-again), and say "Bluetooth permission needed" instead of "Starting mesh…". * fix(notifications): gate peer-count notif on isConnected too (complete SU-D) useMessageNotifications now defers its permission request until onboarding, but usePeerCountNotification still scheduled a notification gated only on `enabled`. Scheduling triggers the iOS permission prompt, and the mesh autostarts + finds peers ~1.5s in (wallet-independent) — so that prompt fired mid-onboarding, partially defeating the deferred-permission choreography. Gate scheduling on isConnected (identity created), matching useMessageNotifications. * fix(nodes): stop the 'Starting mesh…' lie once the node is up The Peers empty state said "Starting mesh…" forever, even after the node was running with zero peers — the common solo case. Distinguish the states: only "Starting mesh…" while !isRunning; once running with no peers, say "No peers nearby yet". Mirrors the L4 Messages honest-empty-state pass for the Nodes tab. * fix(wallet): flag a stale balance instead of showing it as live The new withTimeout on the balance read (this batch) meant a refresh whose balance RPC timed out silently kept the previous SOL figure with the spinner gone and no error — a stale balance presented as current, which a user makes send decisions on. Add a balanceStale signal: keep the last known value (don't blank it to 0/null — a different lie) and show "Balance may be stale — showing last known" next to it. Found by the batch's silent-failure review. * fix(tabs): swipe back to Messages no longer boots to onboarding The tab list used bare '/' for Messages, but '/' is a root route that unconditionally Redirects to /onboarding — so swiping right from Wallet/Peers/ Settings back toward Messages kicked the user out of the app shell into onboarding. Navigate via '/(tabs)' (the path the tutorial + notifications already use to reach Messages) and map '/'+'/(tabs)' to the Messages index. * fix(messages): don't stack the QR scanner over the Join-channel sheet JoinGroupModal kept its own <Modal> visible while presenting QRScannerModal (also a <Modal>), stacking two native modal windows — on iOS that's the documented black-camera + frozen-screen failure, blocking the scan-a-channel- invite join path. Gate the sheet's visible on !scanner so exactly one modal is mounted at a time. (Full scanner-as-route migration still deferred.) * refactor(ui): delete dead components + the unused light theme Verified zero consumers (no direct or barrel imports; no useColorScheme): - components/ui/{Icon,Card,GlassSurface}.tsx — never imported (the live icon is primitives/Icon; surfaces use the useGlass() hook). The 'two Icons / two Pills' collision the design brief feared is, for Icon, just dead code. - theme lightColors — a full light palette with no reader; ThemeProvider is hardcoded dark. Removed so AppColors/the type doesn't imply unsupported light mode. Wiring light would be a feature, not a consistency fix. No appearance change (nothing rendered these). tsc clean. * feat(ui): add AppText + ScreenHeader foundation primitives - AppText: a typography primitive over the shared textVariants scale, so text size/family/line-height come from one source instead of inline fontFamily + fontSize (68 files repeat that today). Additive — migrate forward, no bulk rewrite. - ScreenHeader: the canonical kicker+title masthead with an optional right slot, to replace the 4+ hand-rolled headers that drifted on padding + title weight (some used fontWeight:'600' rather than the SpaceGrotesk SemiBold family). Built on AppText (dogfood). Adoption of ScreenHeader across screens is the next, device-QA'd step. * refactor(wallet): adopt shared ScreenHeader masthead Replace hand-rolled kicker/title header with the canonical ScreenHeader. Network/peers chips and QR button move into the right slot; local kicker and screenTitle styles removed. Title text and right-slot behavior unchanged. * refactor(nodes): adopt shared ScreenHeader masthead Replace hand-rolled peers header with ScreenHeader. Fixes the title weight: it used fontWeight:'600' (synthetic) instead of the SpaceGrotesk SemiBold family, now normalized via headingMd. Keeps the screen's wider spacing[6] gutter. * refactor(contacts): adopt shared ScreenHeader masthead Replace hand-rolled address-book header with ScreenHeader (kicker 'LOCAL ONLY'). Close button moves into the right slot. Title now uses the canonical headingMd (xl/SemiBold) instead of the larger 3xl/Bold one-off; keeps the screen's wider spacing[6] gutter. * refactor(messages): migrate ChannelShareSheet to AppBottomSheet primitive Replace the bespoke Modal + Animated slide + overlay Pressable + grab-bar with the shared AppBottomSheet, so the channel-share sheet inherits the canonical gesture/animation/backdrop/SafeArea instead of a hand-rolled reimplementation. Inner content, copy, QR, copy-rows, screen-capture prevention, and 60s clipboard auto-clear are unchanged. Keep the glass surface via backgroundColor; keep the header X (calls onClose, same as the new drag-dismiss). copied-state reset moved to a visible=false effect since the primitive owns the close animation. * refactor(messages): migrate GroupMembersSheet to AppBottomSheet primitive Replace the bespoke Modal + Animated slide + overlay Pressable + grab-bar with the shared AppBottomSheet. Inner content (header, count row, empty state, member ScrollView) and all logic are unchanged. Keep the glass surface via backgroundColor; keep the header X (calls onClose). The inner ScrollView keeps nestedScrollEnabled and lives within the primitive's 90% maxHeight (was a tighter local 70% cap). * feat(ui): add Toast primitive, replace copy-confirmation Alerts Adds a themed, non-blocking, auto-dismissing Toast (token-driven via useTheme, matching InAppNotificationBanner's style + spring/safe-area pattern). Exposes module-level showToast() backed by a single <ToastHost/> mounted once in AppShell as a sibling of the notification banner. Replaces the three blocking Alert.alert("Copied", ...) native dialogs in SuccessCard and FailureCard with showToast(); clipboard writes unchanged. Native OS alerts for transient clipboard feedback are heavy and break the dark/cyan theme; a toast is the consistent non-blocking pattern. * refactor(nodes): migrate beacon opt-in modal to AppBottomSheet Swap the hand-rolled become-a-beacon Modal (slide Animated + backdrop + grab-bar) for the shared AppBottomSheet primitive. Inner content (header, fee list, CTA) unchanged. Drops the bespoke sheetAnim/sheetY/overlayOp; the stake modal keeps its own animation and is untouched. * feat(ui): themed ConfirmSheet + useConfirm on AppBottomSheet Adds a token-driven confirm/destructive dialog on the shared AppBottomSheet primitive, replacing the OS-native Alert.alert as the app's confirm surface (the only place the dark/cyan theme dropped to platform UI). - module-level confirm({ title, message?, confirmLabel, destructive?, cancelLabel? }): Promise<boolean>, mirroring Toast's host pattern - single <ConfirmHost/> mounted once in AppShell next to ToastHost - resolves true only on confirm; false on cancel/backdrop/drag-dismiss, guarded to fire exactly once per request - DepthButton actions; confirm styled danger/red tone when destructive * refactor(ui): route confirm/destructive Alerts through ConfirmSheet Replaces the multi-button confirm/destructive Alert.alert dialogs with the themed confirm() so destructive flows stay on the dark/cyan surface. Each preserves exact semantics — the destructive action fires iff the user confirms; cancel and backdrop/drag-dismiss do nothing. - contacts.tsx: Delete recipient - RecipientPicker.tsx: Delete recipient (nested confirm under the long-press menu's Delete action) - PeersDrawer.tsx: Leave channel (row springs back to rest on either choice, matching the old cancel/leave branches; onLeave only on confirm) Left as-is: RecipientPicker's outer long-press menu (3-way Cancel/Manage/ Delete — doesn't map to confirm/cancel) and its single-button QR-not- recognised info alert; both remain Alert.alert. * test(tier0): align Solana Pay brand-casing assertions with canonical lowercase 'anonmesh' Base f3b2107 test expected label=AnonMesh, but the app's canonical brand casing is lowercase 'anonmesh' (solanaPayUri.ts emits it; 5 lowercase literals app-wide, zero 'AnonMesh'). Stale test, not a code bug. Matches the identical fix on night2. * feat(ui): ScreenHeader size variant + complete masthead adoption Add size prop mapping to the type scale: 'sm' (default, tabs) = headingMd 20px SemiBold; 'lg' (focal/pushed) = displayMd 30px Bold. Adopt on receive (size=lg renders identically to its prior hand-rolled 30px Bold title — pure consolidation, drops 3 duplicate styles). Set contacts to size=lg to restore the presence lost when it was first shrunk to 20px. Tabs stay sm. All 4 kicker+title screens now share one masthead; size is semantic (tabs vs focal) instead of ad-hoc fontSize/weight per screen. * fix(ui): ConfirmSheet resolves its promise on overlap/unmount instead of hanging Adversarial review found the listener overwrote the in-flight request without resolving it, so a second confirm() (or host unmount) while one was open left the first await hanging forever — violating the file's own resolve-exactly-once contract. Hold the resolver in a ref; a new request or unmount now declines the open one (false). Also removes a stale-closure: settle() no longer depends on request state. Single-confirm dismiss paths (button/backdrop/drag) unchanged. * fix(send): P1 double-send — treat all post-broadcast errors as unknown outcome The submit double-send guard only special-cased TimeoutError. But web3.js rejects with a plain Error (not TimeoutError) on a dropped socket or a 5xx from a load-balanced RPC — which can happen AFTER the node already forwarded the signed tx to the cluster. Those rethrew into ReviewCard's inline 'Try again', which re-signs with a fresh blockhash and lands a SECOND transfer (recipient paid twice). Found in adversarial re-review of the send path. Now: any error whose broadcast outcome is unknown returns the deterministic signature so confirmation polling adjudicates (no resubmit). The sole rethrow is a node-side preflight rejection (SendTransactionError, skipPreflight=false) — provably never broadcast — preserving clear 'insufficient funds' errors + safe inline retry. Verified SendTransactionError is a runtime class (instanceof is sound). tsc 0. * feat(mesh-payment): offline Solana payments through beacon relay (#79) - New executeMeshPayment() — fetch nonce via mesh RPC, derive ATAs, partially sign execute_payment tx with payer key, zero key immediately, send partial tx to beacon over LXMF. Beacon co-signs and submits to Solana. - Fix duplicate rpcAdapter params in buildSplTransferTransaction / estimateSplTransferFeeLamports / sendSplTransfer destructuring. Key never transmitted over mesh; only the partial transaction is relayed. * feat(mesh-rpc): migrate MeshRpcAdapter to beaconRpcWait link protocol (#80) * feat(mesh-rpc): migrate MeshRpcAdapter to beaconRpcWait link protocol Replaces beaconBroadcastRpc (fire-and-forget LXMF broadcast) with beaconRpcWait (Reticulum Link + JSON-RPC 2.0 with zlib compression). - MeshRpcAdapter: swap BeaconBroadcastRpcFn → BeaconRpcWaitFn; rpc() now passes destHashHex and propagates isError as a thrown Error - LxmfContext: expose beaconRpcWait from useLxmf() in LxmfCtxValue - NetworkModeContext: destructure and pass beaconRpcWait to adapter Native module handles correlation, retransmit, and link management. No JS-side message routing needed. * fix(send): remove duplicate rpcAdapter param in buildSplTransferTransaction * feat(mesh-rpc): beacon broadcast RPC + sendTransaction fix (#81) * feat(mesh-rpc): migrate MeshRpcAdapter to beaconRpcWait link protocol Replaces beaconBroadcastRpc (fire-and-forget LXMF broadcast) with beaconRpcWait (Reticulum Link + JSON-RPC 2.0 with zlib compression). - MeshRpcAdapter: swap BeaconBroadcastRpcFn → BeaconRpcWaitFn; rpc() now passes destHashHex and propagates isError as a thrown Error - LxmfContext: expose beaconRpcWait from useLxmf() in LxmfCtxValue - NetworkModeContext: destructure and pass beaconRpcWait to adapter Native module handles correlation, retransmit, and link management. No JS-side message routing needed. * fix(send): remove duplicate rpcAdapter param in buildSplTransferTransaction * fix(mesh-rpc): use beaconBroadcastRpc for RPC queries, keep beaconRpcWait for targeted ops * feat(messages): tab filtering — Contacts / Groups / All Peers - New useConversationSummaries hook: derives last message preview, timestamp, and unread count per peer from native DB. Re-derives on messageReceived events via sliceNewEvents. markRead clears unread count in-memory when conversation is opened. - PeersDrawer: three memoized tabs replacing the flat peer list. Contacts shows only DMs with message history sorted by recency. Groups shows joined group channels. All Peers shows full discovery list. Each tab has a distinct empty state. All three lists wrapped in useMemo to prevent re-computation on unrelated renders. - MessagesScreen: wires hook, passes summaries/activeTab/onTabChange to drawer, calls markRead on peer pick. Default tab is Contacts. * Feature/messages tab filtering (#88) * feat(mesh-payment): offline Solana payments through beacon relay (#79) - New executeMeshPayment() — fetch nonce via mesh RPC, derive ATAs, partially sign execute_payment tx with payer key, zero key immediately, send partial tx to beacon over LXMF. Beacon co-signs and submits to Solana. - Fix duplicate rpcAdapter params in buildSplTransferTransaction / estimateSplTransferFeeLamports / sendSplTransfer destructuring. Key never transmitted over mesh; only the partial transaction is relayed. * feat(mesh-rpc): migrate MeshRpcAdapter to beaconRpcWait link protocol (#80) * feat(mesh-rpc): migrate MeshRpcAdapter to beaconRpcWait link protocol Replaces beaconBroadcastRpc (fire-and-forget LXMF broadcast) with beaconRpcWait (Reticulum Link + JSON-RPC 2.0 with zlib compression). - MeshRpcAdapter: swap BeaconBroadcastRpcFn → BeaconRpcWaitFn; rpc() now passes destHashHex and propagates isError as a thrown Error - LxmfContext: expose beaconRpcWait from useLxmf() in LxmfCtxValue - NetworkModeContext: destructure and pass beaconRpcWait to adapter Native module handles correlation, retransmit, and link management. No JS-side message routing needed. * fix(send): remove duplicate rpcAdapter param in buildSplTransferTransaction * feat(mesh-rpc): beacon broadcast RPC + sendTransaction fix (#81) * feat(mesh-rpc): migrate MeshRpcAdapter to beaconRpcWait link protocol Replaces beaconBroadcastRpc (fire-and-forget LXMF broadcast) with beaconRpcWait (Reticulum Link + JSON-RPC 2.0 with zlib compression). - MeshRpcAdapter: swap BeaconBroadcastRpcFn → BeaconRpcWaitFn; rpc() now passes destHashHex and propagates isError as a thrown Error - LxmfContext: expose beaconRpcWait from useLxmf() in LxmfCtxValue - NetworkModeContext: destructure and pass beaconRpcWait to adapter Native module handles correlation, retransmit, and link management. No JS-side message routing needed. * fix(send): remove duplicate rpcAdapter param in buildSplTransferTransaction * fix(mesh-rpc): use beaconBroadcastRpc for RPC queries, keep beaconRpcWait for targeted ops * feat(messages): tab filtering — Contacts / Groups / All Peers - New useConversationSummaries hook: derives last message preview, timestamp, and unread count per peer from native DB. Re-derives on messageReceived events via sliceNewEvents. markRead clears unread count in-memory when conversation is opened. - PeersDrawer: three memoized tabs replacing the flat peer list. Contacts shows only DMs with message history sorted by recency. Groups shows joined group channels. All Peers shows full discovery list. Each tab has a distinct empty state. All three lists wrapped in useMemo to prevent re-computation on unrelated renders. - MessagesScreen: wires hook, passes summaries/activeTab/onTabChange to drawer, calls markRead on peer pick. Default tab is Contacts. --------- Co-authored-by: Excelsior <33706074+epicexcelsior@users.noreply.github.com> * feat(contacts): open peer conversation → pin to Contacts tab (#89) Opening any peer conversation calls touchPeer(destHash), which persists the hash to AsyncStorage (CONTACTED_PEERS). On next derive, buildSummaries adds a stub ConvSummary for touched peers with no message history yet, so they appear in the Contacts tab immediately and survive app restarts. * fix(observability): scrub raw key bytes + align brand-casing test PII scrubber matched only strings — a Solana secretKey (Uint8Array(64)) or seed (randomBytes(32)) under a generic key (data/args/frame-local) shipped to Sentry unredacted. Redact any binary buffer + long all-numeric array (key serialized as number[]); 2 new tests cover it (10 total, all pass). Also align the stale Solana-Pay brand-casing assertions to canonical lowercase 'anonmesh' (inherited from f3b2107; same fix as night2/design) so the tier0 gate passes. * chore(deps): bump react-native-lxmf to 0.2.73 * fix(lxmf): correct v0.2.73 integration, message corruption, dead code Message corruption: - Add single canonical decodeBody util (strict base64 guard + fatal UTF-8 decode, raw fallback). Route MessagesScreen and useConversationSummaries through it, replacing two divergent decoders. - Delete utils/lxmfDecode.ts (third decoder, zero imports) and the looksReadable heuristic. Wiring: - Drive rnodeConnected from onRNodeConnected/onRNodeDisconnected events (seed once on BLE active) instead of polling connectedRNodeCount() every 1s. - Enable propagation relay: setPropagationNode(true) before start(), and syncPropagation() on app foreground to pull store-and-forward messages. - Surface getConnectedRNodes/unpairNusRNode/syncPropagation on context; UNPAIR now disconnects the native RNode. - Fix broken sliceNewEvents call in useConversationSummaries (was passing refs instead of values, never updated — re-derived every render). - Fix NetworkModeContext passing undefined beaconRpcWait to MeshRpcAdapter; use the destructured beaconBroadcastRpc. TCP path: - Dev-only one-shot logger of announceReceived/messageReceived key sets to confirm the real (untyped) native payload shape over TCP. - A received message defaults a new peer to 'reticulum' (not 'ble') since the message proves reachability, not transport. - Reconcile sends stuck queued/stale over TCP against the native DB acked flag, since a messageDelivered proof may never arrive multi-hop. * fix(lxmf): tag peer transport from the event interface field, not hops TCP-only and RNode peers were shown as BLE. Two heuristics caused it: - resolveVia treated any hops===0 peer as BLE when the BLE radio was on - a re-tag effect stamped every hops===0 peer as BLE whenever blePeerCount>0 Announce events carry no usable hops field, so all peers defaulted to hops=0 and got flagged BLE the moment one real BLE peer connected. The native announce/message event reports the interface the packet arrived on. Read it (probe common key names, substring-map the value to ble/rnode/reticulum) and make it the authoritative source for a peer's transport: - resolveVia now returns eventVia(e) ?? existing ?? 'reticulum' (no hops→ble guess) - applyMessageReceived tags/updates via from the interface field - applyLogAnnounce (parsed log line, no interface) preserves the existing tag - deleted the blanket BLE re-tag effect - dev logger now prints the matched interface field + mapped via to confirm shape * fix(lxmf): guard phantom setPropagationNode/syncPropagation native calls The 0.2.73 type defs declare setPropagationNode and syncPropagation, but neither the iOS nor Android native module registers them — calling the hook wrapper hits an undefined native function and surfaces "syncPropagation is not a function" in the UI error banner. Feature-detect with nativeHasFn() before calling: skip the propagation-node enable on start() and the foreground syncPropagation() when the native build lacks them. They auto-activate once a native build implements them. getConnectedRNodes/unpairNusRNode are registered on both platforms, so they stay unguarded. * fix(messages): close composer/tab-bar gap, show placeholder for undecryptable bodies Gap: MessagesScreen added a View of height insets.bottom below the Composer, but the screen sits above the custom tab bar which already consumes insets.bottom. Double-counted, so the composer floated ~insets.bottom above the tab bar. Removed the spacer (and the now-unused kbShown state + Keyboard listener + import). Body: a received body that is strict base64 but not valid UTF-8 is binary/ciphertext the native layer couldn't decrypt. decodeBody was dumping the raw base64 as the message text (the "doesn't make sense" garbage). It now returns a clear placeholder, "🔒 Encrypted message (couldn't decrypt)", for that case. Real text (base64 of UTF-8) and plain non-base64 bodies are unchanged. * chore(lxmf): bump to 0.2.75, remove dead code 0.2.75 registers setPropagationNode/syncPropagation on both iOS and Android, so the nativeHasFn guards now pass and propagation actually runs. Dead-code cleanup (lint now 0 warnings across the messaging path): - remove unused PROGRAM_ID_HEX const - remove unused ExecutePaymentAccounts/ExecutePaymentParams type mirrors (BeaconExecutePaymentAccounts/Params are the ones in use) - remove unused MediaMsg import (MessagesScreen) - remove unused Icon import (SettingsScreen) - document the intentional per-member useMemo deps with an eslint-disable * fix(messages): capture active peer once per incoming-event batch The incoming-message effect read activePeerHexRef.current per iteration. A tap that runs pickPeer (which mutates the ref synchronously) mid-batch could misroute the remaining same-batch messages to the wrong thread. Snapshot the active peer once at the top of the batch so all messages in a batch route consistently. (Investigation of "only one received message shows" points the dominant cause at the native layer — the LXMF wire timestamp is discarded and all burst messages get SystemTime::now() seconds, plus the 200-cap event buffer's by-reference slicing. Fix prompt for the package is in the plan file.) * feat(lxmf): track events by monotonic id, fixing dropped/duplicated bursts (0.2.76) 0.2.76 stamps every LxmfEvent with a strictly-increasing native id and raises the hook buffer cap to 1000. Replace the head-by-reference sliceNewEvents (not eviction-safe — a burst overflowing the buffer could drop or duplicate unread messages) with an id watermark. - new eventsAfter(events, lastSeenId): returns events with id > lastSeenId in arrival order (oldest-first), O(new) and eviction-safe; delete sliceNewEvents. - migrate all four consumers to a lastSeenId ref: MessagesScreen, LxmfContext, useConversationSummaries, useMessageNotifications. Mount semantics preserved (watermark starts at 0 → process backlog once); notifications advance the watermark even when disabled to avoid replay. - oldest-first ordering also fixes the prior reverse-append of burst messages. Bumps @magicred-1/react-native-lxmf to 0.2.76 (native rebuild required). * fix(lxmf): don't drop id-less events — restore peer announcements Regression from the id-watermark migration: eventsAfter `continue`-skipped any event without a numeric `id`, and the shipped 0.2.76 native build does not stamp `id` on announceReceived (the type is `id?: number`). So every peer announce was silently discarded before reaching the peer map — the peer list stopped populating. (Beacons kept showing because they flow via lxmf.beacons/mergeBeacon, not eventsAfter — the diagnostic tell.) - eventsAfter now includes id-less events instead of skipping them; `id` only bounds the already-seen tail. Safe: the message/summary/notification consumers type-filter to messageReceived (id-bearing), and the announce/peer consumer is idempotent (upsert by hash), so re-surfacing an id-less announce can't dupe. - add highestEventId() to advance the watermark by the max id in the buffer (at(-1) could be an id-less event → undefined → watermark never advanced). - seed lastSeenId at -1 (was 0) so a genuine id=0 first event isn't treated seen. Native follow-ups (not app-fixable, tracked in the prompt): 0.2.76 should stamp id on announces, decode msgpack announce app_data to a clean name, and map the rnodeConnected/rnodeDisconnected events in the Android bridge. * merge: staging (route scanner #85) into night2/integration Resolve scanner overlap in favor of #85's route-based scanner: - drop JoinGroupModal.tsx (superseded by the /join-channel route) - MessagesScreen: join via router.push('/join-channel'); keep night2's isRunning/bleActive honest-empty-state props * chore(lxmf): neutralize dev-gate comment wording * fix(onboarding): drop 'Confidential' overclaim from welcome subtitle Confidential payments aren't in the send path (Arcium = beacon privacy, not payment confidentiality). Reframe to the honest, shipped capability set: encrypted mesh messaging + off-grid payments. Clears the honesty-check gate. * fix(onboarding): drop 'Confidential' overclaim from welcome subtitle Confidential payments aren't in the send path (Arcium = beacon privacy, not payment confidentiality). Reframe to the honest, shipped capability set: encrypted mesh messaging + off-grid payments. Clears the honesty-check gate. * fix(onboarding): drop 'Confidential' overclaim from welcome subtitle Confidential payments aren't in the send path (Arcium = beacon privacy, not payment confidentiality). Reframe to the honest, shipped capability set: encrypted mesh messaging + off-grid payments. Clears the honesty-check gate. * chore(app): bump version to 1.0.3 * chore(deps): sync lockfile to react-native-lxmf 0.2.78 * chore(app): set ios buildNumber + android versionCode to 3 * chore: remove @sentry/react-native (broke CI native upload) The Sentry Gradle step (sentry.gradle source-map/symbol upload via sentry-cli) failed the CI release build (no auth token). Remove the Expo config plugin and the dependency so prebuild no longer wires sentry.gradle. sentry.ts becomes a no-op shim preserving its API (Sentry.wrap/withScope/captureException, initObservability) so app/_layout.tsx and ErrorBoundary.tsx are unchanged and still render the error fallback. The global errorHandler (console.error + native crash path) remains the crash signal. * fix(metro): swap getSentryExpoConfig for getDefaultConfig @sentry/react-native was already removed from package.json and app.json on this branch, but metro.config.js still required its metro wrapper, breaking expo start and any metro bundling step. * fix(network): use beaconBroadcastRpc in MeshRpcAdapter --------- Co-authored-by: Hunter F <33706074+epicexcelsior@users.noreply.github.com> Co-authored-by: epicexcelsior <epicexcelsior@gmail.com>
1 parent 34c3508 commit 46406f8

3 files changed

Lines changed: 38 additions & 63 deletions

File tree

mobile_app/app.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,6 @@
108108
"@magicred-1/react-native-lxmf",
109109
"./plugins/withAndroidForegroundService",
110110
"expo-web-browser",
111-
[
112-
"@sentry/react-native",
113-
{
114-
"organization": "anonmesh",
115-
"project": "react-native",
116-
"url": "https://sentry.io/"
117-
}
118-
],
119111
"expo-audio"
120112
],
121113
"experiments": {

mobile_app/metro.config.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
// getSentryExpoConfig wraps Expo's default Metro config to enable source-map
2-
// upload for crash symbolication. It is a drop-in for getDefaultConfig; if the
3-
// Sentry integration is ever removed, swap this line back to getDefaultConfig.
4-
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
1+
// Plain Expo Metro config. (Sentry's getSentryExpoConfig wrapper was removed
2+
// along with @sentry/react-native.)
3+
const { getDefaultConfig } = require('expo/metro-config');
54
const path = require('node:path');
65

7-
const config = getSentryExpoConfig(__dirname);
6+
const config = getDefaultConfig(__dirname);
87

98
config.resolver.unstable_enablePackageExports = true;
109
config.resolver.unstable_conditionNames = [
Lines changed: 34 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,44 @@
11
/**
2-
* Crash/error observability — Sentry, crash-only, privacy-scrubbed.
2+
* Crash/error observability — Sentry removed.
33
*
4-
* Scope is deliberately narrow: unhandled JS/native crashes + handled React
5-
* errors. NO product analytics, NO user identity, NO performance tracing.
6-
* See AI Briefs/Anonmesh/telemetry.md for the rationale.
4+
* `@sentry/react-native` was removed: its native Gradle step (`sentry.gradle`
5+
* source-map/symbol upload via `sentry-cli`) broke CI, and the app opted out of
6+
* third-party crash reporting. This is a no-op shim that preserves the former
7+
* import surface (`Sentry`, `initObservability`, `observabilityEnabled`) so
8+
* `app/_layout.tsx` and `ErrorBoundary.tsx` compile and run unchanged. The
9+
* ErrorBoundary still renders its fallback UI — it just no longer reports.
710
*
8-
* KILL SWITCH: this is a no-op unless EXPO_PUBLIC_SENTRY_DSN is set AND the
9-
* build is not __DEV__. Unset the env var (or delete this module's call site in
10-
* app/_layout.tsx) and the app sends nothing. Nothing here runs in development.
11+
* The `errorHandler` global handler (console.error + native crash path) is
12+
* unaffected and remains the crash signal.
1113
*/
12-
import * as Sentry from '@sentry/react-native';
13-
import type { ErrorEvent, Breadcrumb } from '@sentry/react-native';
1414

15-
import { scrubDeep } from './scrub';
15+
/** Always false — Sentry is gone. Kept for callers that gate on it. */
16+
export const observabilityEnabled = false;
1617

17-
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN;
18-
19-
/**
20-
* Telemetry is OFF in development and OFF whenever no DSN is configured. A
21-
* release build with no DSN ships completely inert — this is the removal path.
22-
*/
23-
export const observabilityEnabled = Boolean(DSN) && !__DEV__;
24-
25-
function beforeSend(event: ErrorEvent): ErrorEvent {
26-
// Belt-and-suspenders: never attach a user even if something tries to.
27-
delete event.user;
28-
return scrubDeep(event);
29-
}
30-
31-
function beforeBreadcrumb(crumb: Breadcrumb): Breadcrumb | null {
32-
// Navigation breadcrumbs can carry route params (e.g. ?to=<address>) — drop
33-
// their data and keep only the scrubbed message text.
34-
if (crumb.data) crumb.data = {};
35-
return scrubDeep(crumb);
36-
}
37-
38-
/**
39-
* Initialise crash observability. Safe to call unconditionally — it no-ops
40-
* unless {@link observabilityEnabled}. Call once at app entry, before render.
41-
*/
18+
/** No-op: Sentry removed. Safe to call once at app entry. */
4219
export function initObservability(): void {
43-
if (!observabilityEnabled) return;
20+
/* no-op */
21+
}
4422

45-
Sentry.init({
46-
dsn: DSN,
47-
enabled: true,
48-
// Crash-only: no performance/tracing spans, no profiling.
49-
tracesSampleRate: 0,
50-
// Never collect IP, cookies, or default request PII.
51-
sendDefaultPii: false,
52-
// Keep breadcrumb history short; they are scrubbed but fewer is safer.
53-
maxBreadcrumbs: 30,
54-
attachStacktrace: true,
55-
beforeSend,
56-
beforeBreadcrumb,
57-
});
23+
interface NoopScope {
24+
setTag(key: string, value: string): void;
25+
setContext(key: string, value: Record<string, unknown> | null): void;
5826
}
5927

60-
export { Sentry };
28+
const noopScope: NoopScope = {
29+
setTag: () => {},
30+
setContext: () => {},
31+
};
32+
33+
/** No-op stand-in for the `@sentry/react-native` API the app referenced. */
34+
export const Sentry = {
35+
wrap<T>(component: T): T {
36+
return component;
37+
},
38+
withScope(callback: (scope: NoopScope) => void): void {
39+
callback(noopScope);
40+
},
41+
captureException(_error: unknown): void {
42+
/* no-op */
43+
},
44+
};

0 commit comments

Comments
 (0)