Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 47 additions & 12 deletions mobile_app/components/messages/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<SendState, { icon: React.ComponentProps<typeof Feather>['name']; label: string }> = {
Expand Down Expand Up @@ -46,26 +51,30 @@ function FileRow({ file, colors }: {
readonly colors: ReturnType<typeof useTheme>['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 (
<Pressable
onPress={() => Alert.alert(file.name, `${sizeKb} KB — file saving coming soon`)}
style={({ pressed }) => [S.fileRow, { backgroundColor: pressed ? colors.surface2 : colors.surface1, borderColor: colors.border }]}
>
<View style={[S.fileRow, { backgroundColor: colors.surface1, borderColor: colors.border }]}>
<Feather name="file" size={13} color={colors.textSecondary} />
<Text style={[S.fileName, { color: colors.textPrimary }]} numberOfLines={1}>{file.name}</Text>
<Text style={[S.fileSize, { color: colors.textTertiary }]}>{sizeKb} KB</Text>
<Feather name="download" size={12} color={colors.textTertiary} />
</Pressable>
</View>
);
}

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');
Expand All @@ -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 (
<View style={[S.wrap, { alignItems: m.me ? 'flex-end' : 'flex-start' }]}>
<View style={[S.meta, { justifyContent: m.me ? 'flex-end' : 'flex-start' }]}>
Expand All @@ -84,13 +113,19 @@ export const MessageBubble = memo(function MessageBubble({ m, sendState }: Props
{m.enc && <Feather name="lock" size={10} color={colors.primary} style={{ marginLeft: 4 }} />}
</View>
<Pressable
onLongPress={hasText ? copyText : undefined}
onLongPress={handleLongPress}
delayLongPress={350}
accessibilityHint={hasText ? 'long press to copy message text' : undefined}
accessibilityHint={
canManage
? 'long press to resend or discard this message'
: hasText
? 'long press to copy message text'
: undefined
}
style={({ pressed }) => [
S.bubble, glass,
{ borderBottomRightRadius: m.me ? 4 : 16, borderBottomLeftRadius: m.me ? 16 : 4 },
pressed && hasText && S.bubblePressed,
pressed && !!handleLongPress && S.bubblePressed,
]}
>
{hasText && (
Expand Down
132 changes: 7 additions & 125 deletions mobile_app/components/nodes/BeaconRegistry.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<TextInput>(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
Expand All @@ -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 (
<>
Expand Down Expand Up @@ -122,11 +97,11 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini
<View style={S.repCell}>
<Text style={[S.repNum, { color: colors.textPrimary }]}>{jitoAmt}</Text>
<Text style={[S.repLabel, { color: colors.textTertiary }]}>JITOSOL</Text>
{/* Stake button disabled until staking flow is live */}
<View style={[S.stakeChip, { borderColor: colors.border, backgroundColor: colors.surface2, opacity: 0.4 }]}>
<Feather name="plus" size={9} color={colors.textTertiary} />
<Text style={[S.stakeChipText, { color: colors.textTertiary }]}>Soon</Text>
</View>
{/* 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. */}
<Pill label="SOON" variant="default" style={S.stakePill} />
</View>
<View style={[S.repDivider, { backgroundColor: colors.borderSubtle }]} />
<View style={S.repCell}>
Expand Down Expand Up @@ -222,84 +197,6 @@ export const BeaconRegistry = memo(function BeaconRegistry({ initialActive: _ini
</Animated.View>
</View>
</Modal>

{/* ── Stake Modal ── */}
<Modal visible={stakeModal} transparent animationType="none" onRequestClose={dismissStake}>
<KeyboardAvoidingView style={StyleSheet.absoluteFill} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(4,4,6,0.75)', opacity: stakeOvOp }]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismissStake} />
</Animated.View>

<Animated.View style={[S.sheet, { backgroundColor: colors.glass, borderColor: colors.border, transform: [{ translateY: stakeSheetY }] }]}>
<View style={[S.grab, { backgroundColor: colors.border }]} />

<View style={S.sheetHeader}>
<Text style={[S.sheetTitle, { color: colors.textPrimary }]}>Stake More SOL</Text>
<Pressable onPress={dismissStake} style={[S.closeBtn, softGlass]} hitSlop={8}>
<Feather name="x" size={14} color={colors.textSecondary} />
</Pressable>
</View>

{/* Stepper */}
<View style={[S.stepper, { backgroundColor: colors.surface2, borderColor: colors.border }]}>
<Pressable
onPress={() => 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 }]}
>
<Feather name="minus" size={20} color={colors.textPrimary} />
</Pressable>
<View style={S.stepCenter}>
<SolanaIcon size={28} color={colors.primary} />
<TextInput
ref={amtInputRef}
style={[S.stepAmt, { color: colors.textPrimary }]}
value={rawAmt}
onChangeText={setRawAmt}
onBlur={() => commitAmt(Number.parseFloat(rawAmt) || 0.5)}
onSubmitEditing={() => commitAmt(Number.parseFloat(rawAmt) || 0.5)}
keyboardType="decimal-pad"
returnKeyType="done"
selectTextOnFocus
/>
<Text style={[S.stepUnit, { color: colors.textTertiary }]}>SOL</Text>
</View>
<Pressable
onPress={() => commitAmt(stakeAmt + 0.5)}
hitSlop={16}
style={({ pressed }) => [S.stepBtn, { opacity: pressed ? 0.35 : 1 }]}
>
<Feather name="plus" size={20} color={colors.textPrimary} />
</Pressable>
</View>

{/* Impact rows */}
<View style={[S.impactBox, { backgroundColor: colors.surface2, borderColor: colors.border }]}>
<View style={[S.impactRow, { borderBottomColor: colors.borderSubtle }]}>
<Feather name="shield" size={14} color={colors.primary} />
<Text style={[S.impactRowLabel, { color: colors.textSecondary }]}>Rep score</Text>
<Text style={[S.impactRowBefore, { color: colors.textTertiary }]}>{repScore}</Text>
<Feather name="arrow-right" size={10} color={colors.textTertiary} />
<Text style={[S.impactRowAfter, { color: colors.primary }]}>{newRep}</Text>
</View>
<View style={S.impactRow}>
<Feather name="trending-up" size={14} color={colors.primary} />
<Text style={[S.impactRowLabel, { color: colors.textSecondary }]}>Yield / yr</Text>
<Text style={[S.impactRowBefore, { color: colors.textTertiary }]}>+{yieldAmt}</Text>
<Feather name="arrow-right" size={10} color={colors.textTertiary} />
<Text style={[S.impactRowAfter, { color: colors.primary }]}>+{newYield} SOL</Text>
</View>
</View>

<View
style={[S.actionBtn, { backgroundColor: colors.surface2, borderWidth: 0.5, borderColor: colors.border, opacity: 0.6 }]}
pointerEvents="none"
>
<Text style={[S.actionText, { color: colors.textTertiary }]}>Preview — not yet active</Text>
</View>
</Animated.View>
</KeyboardAvoidingView>
</Modal>
</>
);
});
Expand All @@ -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 },
Expand All @@ -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' },
});
41 changes: 38 additions & 3 deletions mobile_app/components/send/ReviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI

const [error, setError] = useState<ReviewError | null>(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<TxPhase>(null);
const [sliderResetKey, setSliderResetKey] = useState(0);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ? (
<Pressable
accessibilityLabel="Retry fee estimate"
accessibilityRole="button"
hitSlop={8}
onPress={handleRetryFee}
style={S.feeRetryRow}
>
<Text style={[S.detailValue, { color: colors.textTertiary }]}>
Fee unavailable
</Text>
<Feather name="rotate-ccw" size={13} color={colors.primary} />
</Pressable>
) : undefined
}
/>
</View>

Expand Down Expand Up @@ -709,6 +739,11 @@ const S = StyleSheet.create({
flexDirection: "row",
gap: 6,
},
feeRetryRow: {
alignItems: "center",
flexDirection: "row",
gap: 6,
},

// stealth tile
stealthTile: {
Expand Down
2 changes: 1 addition & 1 deletion mobile_app/components/send/TokenPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
</Text>
</View>
Expand Down
Loading
Loading