diff --git a/mobile_app/components/messages/MessageBubble.tsx b/mobile_app/components/messages/MessageBubble.tsx index f42501e..7caa0d5 100644 --- a/mobile_app/components/messages/MessageBubble.tsx +++ b/mobile_app/components/messages/MessageBubble.tsx @@ -1,5 +1,5 @@ import React, { memo, useCallback } from 'react'; -import { View, Text, Pressable, Alert, StyleSheet } from 'react-native'; +import { Alert, View, Text, Pressable, StyleSheet } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { Feather } from '@expo/vector-icons'; import { useTheme, fontFamily, fontSize, radii } from '@/theme'; @@ -13,6 +13,11 @@ type SendState = 'sent' | 'queued' | 'delivered' | 'failed' | 'stale'; interface Props { readonly m: ChatMsg; readonly sendState?: SendState; + // Resend / discard an outbound bubble that's stuck. Only wired for the + // sender's own messages; passed down from MessagesScreen which owns the + // send + seq bookkeeping. + readonly onResend?: (msgId: number) => void; + readonly onDiscard?: (msgId: number) => void; } const STATE_META: Record['name']; label: string }> = { @@ -46,26 +51,30 @@ function FileRow({ file, colors }: { readonly colors: ReturnType['colors']; }) { const sizeKb = Math.round((file.data.length * 3) / 4 / 1024); + // Saving attachments to disk isn't wired up yet, so this is a static info + // row — not a Pressable. A tap-handler that only popped a "coming soon" + // alert was a button that lied; better to not look tappable at all. return ( - Alert.alert(file.name, `${sizeKb} KB — file saving coming soon`)} - style={({ pressed }) => [S.fileRow, { backgroundColor: pressed ? colors.surface2 : colors.surface1, borderColor: colors.border }]} - > + {file.name} {sizeKb} KB - - + ); } -export const MessageBubble = memo(function MessageBubble({ m, sendState }: Props) { +export const MessageBubble = memo(function MessageBubble({ m, sendState, onResend, onDiscard }: Props) { const { colors } = useTheme(); const glass = useGlass(m.me ? 'accent' : 'base'); const hasText = m.text.length > 0; + // A stuck outbound bubble — either it never left the queue past the stale + // window, or the send failed outright. These are the only states where + // resend/discard makes sense; a delivered/sent message offers copy only. + const isStuck = m.me && (sendState === 'stale' || sendState === 'failed'); + const canManage = isStuck && (!!onResend || !!onDiscard); + const copyText = useCallback(async () => { - haptics.lightPress(); try { await Clipboard.setStringAsync(m.text); showToast('Message copied'); @@ -76,6 +85,26 @@ export const MessageBubble = memo(function MessageBubble({ m, sendState }: Props } }, [m.text]); + // Long-press menu for a stuck send: resend re-runs the original send, + // discard drops the bubble. Copy stays available when there's text. + const showStuckMenu = useCallback(() => { + haptics.lightPress(); + const verb = sendState === 'failed' ? 'failed to send' : 'still waiting for the peer'; + const options: { text: string; style?: 'cancel' | 'destructive'; onPress?: () => void }[] = [ + { text: 'Resend', onPress: onResend ? () => onResend(m.id) : undefined }, + ...(hasText ? [{ text: 'Copy text', onPress: () => { void copyText(); } }] : []), + { text: 'Discard', style: 'destructive' as const, onPress: onDiscard ? () => onDiscard(m.id) : undefined }, + { text: 'Cancel', style: 'cancel' as const }, + ]; + Alert.alert('Message ' + verb, undefined, options, { cancelable: true }); + }, [m.id, sendState, hasText, onResend, onDiscard, copyText]); + + const handleLongPress = canManage + ? showStuckMenu + : hasText + ? () => { haptics.lightPress(); void copyText(); } + : undefined; + return ( @@ -84,13 +113,19 @@ export const MessageBubble = memo(function MessageBubble({ m, sendState }: Props {m.enc && } [ S.bubble, glass, { borderBottomRightRadius: m.me ? 4 : 16, borderBottomLeftRadius: m.me ? 16 : 4 }, - pressed && hasText && S.bubblePressed, + pressed && !!handleLongPress && S.bubblePressed, ]} > {hasText && ( diff --git a/mobile_app/components/nodes/BeaconRegistry.tsx b/mobile_app/components/nodes/BeaconRegistry.tsx index 623a3f1..0cbdaca 100644 --- a/mobile_app/components/nodes/BeaconRegistry.tsx +++ b/mobile_app/components/nodes/BeaconRegistry.tsx @@ -1,5 +1,5 @@ import React, { memo, useState, useRef, useCallback } from 'react'; -import { Animated, KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Animated, Modal, Pressable, StyleSheet, Text, View } from 'react-native'; import { Feather } from '@expo/vector-icons'; import { fontFamily, useTheme } from '@/theme'; import { useGlass } from '@/hooks/useGlass'; @@ -33,12 +33,7 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini const cosigns = 0; const earned = 0; const [modal, setModal] = useState(false); - const [stakeModal, setStakeModal] = useState(false); - const [stakeAmt, setStakeAmt] = useState(0.5); - const [rawAmt, setRawAmt] = useState('0.5'); - const amtInputRef = useRef(null); const sheetAnim = useRef(new Animated.Value(0)).current; - const stakeAnim = useRef(new Animated.Value(0)).current; // Auto-activate on first internet was removed per AUDIT T10 / ROADMAP § 0.B.3: // beacon mode carries trust implications (relaying others' traffic) so the @@ -61,33 +56,13 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini dismiss(); }, [setBeaconMode, dismiss]); - const commitAmt = useCallback((v: number) => { - const n = Math.max(0.5, Number.parseFloat(Math.max(0.5, v).toFixed(1))); - setStakeAmt(n); - setRawAmt(n.toFixed(1)); - }, []); - - const openStake = useCallback(() => { - setStakeModal(true); - Animated.spring(stakeAnim, { toValue: 1, useNativeDriver: true, bounciness: 4 }).start(); - }, [stakeAnim]); - - const dismissStake = useCallback(() => { - Animated.timing(stakeAnim, { toValue: 0, duration: 220, useNativeDriver: true }) - .start(() => setStakeModal(false)); - }, [stakeAnim]); - const sheetY = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [500, 0], extrapolate: 'clamp' }); const overlayOp = sheetAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }); - const stakeSheetY = stakeAnim.interpolate({ inputRange: [0, 1], outputRange: [500, 0], extrapolate: 'clamp' }); - const stakeOvOp = stakeAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }); // These values are not live — show placeholder dashes under PREVIEW label. const jitoAmt = '—'; const yieldAmt = '—'; const repScore = '—'; - const newRep = '—'; - const newYield = '—'; return ( <> @@ -122,11 +97,11 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini {jitoAmt} JITOSOL - {/* Stake button disabled until staking flow is live */} - - - Soon - + {/* Staking isn't live yet. A dimmed-out fake button read as + broken; the canonical PREVIEW/SOON Pill reads as an + intentional roadmap marker instead. No handler — there's + nothing real to wire to. */} + @@ -222,84 +197,6 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini - - {/* ── Stake Modal ── */} - - - - - - - - - - - Stake More SOL - - - - - - {/* Stepper */} - - setStakeAmt(a => Math.max(0.5, Number.parseFloat((a - 0.5).toFixed(1))))} - hitSlop={16} - style={({ pressed }) => [S.stepBtn, { opacity: pressed || stakeAmt <= 0.5 ? 0.35 : 1 }]} - > - - - - - commitAmt(Number.parseFloat(rawAmt) || 0.5)} - onSubmitEditing={() => commitAmt(Number.parseFloat(rawAmt) || 0.5)} - keyboardType="decimal-pad" - returnKeyType="done" - selectTextOnFocus - /> - SOL - - commitAmt(stakeAmt + 0.5)} - hitSlop={16} - style={({ pressed }) => [S.stepBtn, { opacity: pressed ? 0.35 : 1 }]} - > - - - - - {/* Impact rows */} - - - - Rep score - {repScore} - - {newRep} - - - - Yield / yr - +{yieldAmt} - - +{newYield} SOL - - - - - Preview — not yet active - - - - ); }); @@ -323,8 +220,7 @@ const S = StyleSheet.create({ repNum: { fontFamily: fontFamily.sansBold, fontSize: 16, letterSpacing: -0.5 }, repLabel: { fontFamily: fontFamily.sansMd, fontSize: 9, letterSpacing: 2, textTransform: 'uppercase' }, repDivider: { width: 0.5, marginVertical: 4 }, - stakeChip: { flexDirection: 'row', alignItems: 'center', gap: 3, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10, borderWidth: 0.5, marginTop: 2 }, - stakeChipText: { fontFamily: fontFamily.sansMd, fontSize: 9, letterSpacing: 0.5 }, + stakePill: { marginTop: 2 }, footer: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 12 }, footerStat: { fontFamily: fontFamily.sansMd, fontSize: 12 }, @@ -351,18 +247,4 @@ const S = StyleSheet.create({ actionBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 15, borderRadius: 14 }, actionText: { fontFamily: fontFamily.sansMd, fontSize: 14, fontWeight: '600', letterSpacing: 0.2 }, - - stepper: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - borderRadius: 18, borderWidth: 0.5, paddingHorizontal: 20, paddingVertical: 18, marginBottom: 14 }, - stepBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }, - stepCenter: { flexDirection: 'row', alignItems: 'center', gap: 6 }, - stepAmt: { fontFamily: fontFamily.sansBold, fontSize: 36, letterSpacing: -1.5 }, - stepUnit: { fontFamily: fontFamily.sansMd, fontSize: 14, letterSpacing: 0.5, marginBottom: 2 }, - - impactBox: { borderRadius: 14, borderWidth: 0.5, overflow: 'hidden', marginBottom: 20 }, - impactRow: { flexDirection: 'row', alignItems: 'center', gap: 8, - paddingHorizontal: 14, paddingVertical: 14, borderBottomWidth: 0.5 }, - impactRowLabel: { flex: 1, fontFamily: fontFamily.sansMd, fontSize: 13 }, - impactRowBefore:{ fontFamily: fontFamily.sansMd, fontSize: 13 }, - impactRowAfter: { fontFamily: fontFamily.sansMd, fontSize: 13, fontWeight: '600' }, }); diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index 38accfe..2a5f135 100644 --- a/mobile_app/components/send/ReviewCard.tsx +++ b/mobile_app/components/send/ReviewCard.tsx @@ -132,6 +132,10 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI const [error, setError] = useState(null); const [feeLabel, setFeeLabel] = useState("Calculating..."); + // Bumped to re-run the best-effort fee estimate after it fell back to + // "Fee unavailable" (timeout / RPC reject). The retry affordance next to the + // fee row increments this; the estimate effect lists it as a dependency. + const [feeNonce, setFeeNonce] = useState(0); const [isConfirming, setIsConfirming] = useState(false); const [txPhase, setTxPhase] = useState(null); const [sliderResetKey, setSliderResetKey] = useState(0); @@ -206,7 +210,7 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI return () => { cancelled = true; }; - }, [amount, normalizedMint, normalizedProgramId, symbol, to, tokenDecimals, wallet]); + }, [amount, feeNonce, normalizedMint, normalizedProgramId, symbol, to, tokenDecimals, wallet]); async function handleConfirm() { if (isConfirming) return; @@ -223,7 +227,7 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI if (symbol !== "SOL" && isToken2022) { setError({ kind: "unsupported", - message: `${symbol} is a Token-2022 mint. Token-2022 sends are not supported yet — coming soon.`, + message: `${symbol} uses the Token-2022 program. anonmesh can send SOL only right now — Token-2022 mints can carry transfer-fee and confidential-transfer extensions the standard transfer path can't safely handle. You can still receive and hold it.`, }); setSliderResetKey((k) => k + 1); return; @@ -390,6 +394,16 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI handleConfirm(); } + // Re-run the best-effort fee estimate. The fee read is non-blocking — sending + // never depends on it — so this only refreshes the displayed label after a + // timeout/RPC reject left it on "Fee unavailable". + function handleRetryFee() { + haptics.tap(); + setFeeNonce((n) => n + 1); + } + + const feeUnavailable = feeLabel === "Fee unavailable"; + // Loader sublabel. Only the online adapter talks to the app's hard-wired // devnet RPC (src/infrastructure/network/connection.ts), so "on devnet" is // only an honest claim there. In mesh mode the transaction is relayed @@ -559,7 +573,23 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI icon="zap" label="Fee" secondary="Estimated from network RPC" - value={feeLabel} + value={feeUnavailable ? undefined : feeLabel} + valueComponent={ + feeUnavailable ? ( + + + Fee unavailable + + + + ) : undefined + } /> @@ -709,6 +739,11 @@ const S = StyleSheet.create({ flexDirection: "row", gap: 6, }, + feeRetryRow: { + alignItems: "center", + flexDirection: "row", + gap: 6, + }, // stealth tile stealthTile: { diff --git a/mobile_app/components/send/TokenPicker.tsx b/mobile_app/components/send/TokenPicker.tsx index 0ec4002..58d97ce 100644 --- a/mobile_app/components/send/TokenPicker.tsx +++ b/mobile_app/components/send/TokenPicker.tsx @@ -166,7 +166,7 @@ export function TokenPicker({ visible, selected, onSelect, onClose }: TokenPicke }} > {hiddenSplCount > 0 - ? "Token sends temporarily SOL-only — coming soon" + ? "Sends are SOL-only on devnet right now. SPL tokens are view-only — you can hold and receive them." : "Balances pulled live from devnet"} diff --git a/mobile_app/screens/MessagesScreen.tsx b/mobile_app/screens/MessagesScreen.tsx index 9dcaa1f..fe8f1e2 100644 --- a/mobile_app/screens/MessagesScreen.tsx +++ b/mobile_app/screens/MessagesScreen.tsx @@ -82,7 +82,13 @@ function reconcilePendingSends( }); } -function renderMsg(m: AnyMsg, getSendState: GetSendState): React.ReactElement { +interface BubbleActions { + getSendState: GetSendState; + onResend: (msgId: number) => void; + onDiscard: (msgId: number) => void; +} + +function renderMsg(m: AnyMsg, actions: BubbleActions): React.ReactElement { if (m.kind === 'sys') return ; if (m.kind === 'tx') return ; if (m.kind === 'request-money') return ; @@ -90,7 +96,15 @@ function renderMsg(m: AnyMsg, getSendState: GetSendState): React.ReactElement { if (m.kind === 'share-address') return ; if (m.kind === 'media') return ; const chat: ChatMsg = m; - return ; + return ( + + ); } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -666,13 +680,11 @@ export default function MessagesScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps []); - const sendMsg = useCallback(async (text: string) => { - const now = new Date().toTimeString().slice(0, 8); - const msgId = nextId(); - // enc:false until the native module emits messageDelivered for this seq — - // a lock icon on a still-queued (or eventually failed) send is a false - // present-tense claim per AUDIT T9 / ROADMAP § 0.3. - setMsgs(m => [...m, { id: msgId, from: 'me', me: true, time: now, text, enc: false }]); + // Dispatch the native send for an already-rendered outbound text bubble and + // wire up its queued/sent/failed bookkeeping. Shared by the first send + // (sendMsg) and a manual resend of a stuck bubble (onResendMsg), so a retry + // walks the exact same path instead of forging delivery state. + const dispatchTextSend = useCallback(async (msgId: number, text: string) => { const pseudoSeq = beginOutbound(msgId); if (!activePeerHex) { resolveSeq(pseudoSeq, 'failed'); @@ -703,6 +715,72 @@ export default function MessagesScreen() { adoptSeq(msgId, pseudoSeq, seq); }, [activePeerHex, isRunning, send, resolveSeq, beginOutbound, adoptSeq]); + const sendMsg = useCallback(async (text: string) => { + const now = new Date().toTimeString().slice(0, 8); + const msgId = nextId(); + // enc:false until the native module emits messageDelivered for this seq — + // a lock icon on a still-queued (or eventually failed) send is a false + // present-tense claim per AUDIT T9 / ROADMAP § 0.3. + setMsgs(m => [...m, { id: msgId, from: 'me', me: true, time: now, text, enc: false }]); + await dispatchTextSend(msgId, text); + }, [dispatchTextSend]); + + // Resend a stuck (stale/failed) outbound text bubble. Re-runs the original + // send under the SAME bubble id so its status updates in place rather than + // spawning a duplicate. Clears the old seq bookkeeping first so the prior + // pseudo/real seq can't keep reporting the dead attempt's state. + const onResendMsg = useCallback((msgId: number) => { + const target = msgsRef.current.find(x => x.id === msgId); + if (!target || target.kind !== undefined || !target.me) return; + const text = target.text; + if (!text) return; + const prevSeq = idToSeqRef.current.get(msgId); + if (prevSeq !== undefined) { + clearTimeout(immediateTimers.current.get(prevSeq)); + immediateTimers.current.delete(prevSeq); + seqQueuedAt.current.delete(prevSeq); + pendingSendsRef.current.delete(prevSeq); + idToSeqRef.current.delete(msgId); + setSeqStates(m => { + if (!m.has(prevSeq)) return m; + const next = new Map(m); + next.delete(prevSeq); + return next; + }); + } + void dispatchTextSend(msgId, text); + }, [dispatchTextSend]); + + // Discard a stuck outbound bubble: drop it from the visible thread and any + // cached thread, and forget its seq bookkeeping so a late native event can't + // resurrect a status for a message the user removed. + const onDiscardMsg = useCallback((msgId: number) => { + const prevSeq = idToSeqRef.current.get(msgId); + if (prevSeq !== undefined) { + clearTimeout(immediateTimers.current.get(prevSeq)); + immediateTimers.current.delete(prevSeq); + seqQueuedAt.current.delete(prevSeq); + pendingSendsRef.current.delete(prevSeq); + idToSeqRef.current.delete(msgId); + setSeqStates(m => { + if (!m.has(prevSeq)) return m; + const next = new Map(m); + next.delete(prevSeq); + return next; + }); + } + const drop = (list: AnyMsg[]) => list.filter(x => x.id !== msgId); + setMsgs(prev => drop(prev)); + threadsRef.current.forEach((thread, peerHash) => { + if (thread.some(x => x.id === msgId)) threadsRef.current.set(peerHash, drop(thread)); + }); + }, []); + + const bubbleActions = useMemo( + () => ({ getSendState, onResend: onResendMsg, onDiscard: onDiscardMsg }), + [getSendState, onResendMsg, onDiscardMsg], + ); + const handleMedia = useCallback(async (media: MediaPayload) => { const now = new Date().toTimeString().slice(0, 8); const msgId = nextId(); @@ -943,7 +1021,7 @@ export default function MessagesScreen() { showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" > - {msgs.map(m => renderMsg(m, getSendState))} + {msgs.map(m => renderMsg(m, bubbleActions))} diff --git a/mobile_app/screens/SettingsScreen.tsx b/mobile_app/screens/SettingsScreen.tsx index 16104a4..1f76584 100644 --- a/mobile_app/screens/SettingsScreen.tsx +++ b/mobile_app/screens/SettingsScreen.tsx @@ -125,10 +125,10 @@ export default function SettingsScreen() { return ( - + {/* ── Swipeable identity card ── */} - + setCardWidth(e.nativeEvent.layout.width)} @@ -215,7 +215,7 @@ export default function SettingsScreen() { {/* ── Paired hardware ── */} setPairOpen(true)} style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}> + setPairOpen(true)} style={{ flexDirection: 'row', alignItems: 'center', gap: spacing[2] }}> ADD @@ -223,10 +223,10 @@ export default function SettingsScreen() { hardware radio - + {paired ? ( - + @@ -283,7 +283,7 @@ export default function SettingsScreen() { {/* ── Privacy & security ── */} privacy & security - + { if (v) setBiometric(true); else setDisableBioOpen(true); }} />} /> } onPress={() => router.push(CONTACTS_ROUTE)} /> @@ -294,7 +294,7 @@ export default function SettingsScreen() { {/* ── Network ── */} network - + {/* Cellular fallback isn't wired to any transport yet. Wrap it in the same preview shield as the unbuilt hardware controls so the toggle @@ -309,7 +309,7 @@ export default function SettingsScreen() { {/* ── About ── */} about - + {Constants.expoConfig?.version ?? '—'}} last /> @@ -342,7 +342,7 @@ const S = StyleSheet.create({ // ── Identity card ──────────────────────────────────────────────────────────── identityCard: { borderRadius: radii.xl, overflow: 'hidden' }, - slide: { paddingHorizontal: spacing[6], paddingTop: spacing[6], paddingBottom: 4, gap: 14, alignItems: 'center' }, + slide: { paddingHorizontal: spacing[6], paddingTop: spacing[6], paddingBottom: spacing[2], gap: 14, alignItems: 'center' }, idLabel: { fontFamily: fontFamily.sansMd, fontSize: 9.5, letterSpacing: 2.5, textTransform: 'uppercase' }, qrWrap: { padding: spacing[3], borderRadius: radii.md, borderWidth: 0.5, overflow: 'hidden' }, idHandle: { fontFamily: fontFamily.sansMd, fontSize: fontSize.md, letterSpacing: 0.3 }, @@ -371,7 +371,7 @@ const S = StyleSheet.create({ hwActionWrap: { flex: 1 }, hwActionBtn: { flex: 1, padding: 9, borderRadius: radii.md, alignItems: 'center' }, hwActionText: { fontFamily: fontFamily.sansMd, fontSize: 10, letterSpacing: 1.5, textTransform: 'uppercase' }, - addHwBtn: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: spacing[5], borderRadius: radii.lg }, + addHwBtn: { flexDirection: 'row', alignItems: 'center', gap: spacing[4], padding: spacing[5], borderRadius: radii.lg }, signOut: { marginTop: 10, padding: 13, borderRadius: radii.md, borderWidth: 0.5, alignItems: 'center', backgroundColor: 'transparent' }, signOutText: { fontFamily: fontFamily.sansMd, fontSize: fontSize.xs, fontWeight: '500', letterSpacing: 3, textTransform: 'uppercase' }, }); diff --git a/mobile_app/screens/WalletScreen.tsx b/mobile_app/screens/WalletScreen.tsx index 6625516..c43bbbf 100644 --- a/mobile_app/screens/WalletScreen.tsx +++ b/mobile_app/screens/WalletScreen.tsx @@ -211,10 +211,10 @@ function ActivityTile({ refreshing, onRefresh }: { readonly refreshing: boolean; {!initialLoad && activityError && ( - + Couldn't load activity - + {activityError} - + No transactions yet