diff --git a/mobile_app/components/nodes/PendingCosigns.tsx b/mobile_app/components/nodes/PendingCosigns.tsx index 15cdb1a7..6dc19656 100644 --- a/mobile_app/components/nodes/PendingCosigns.tsx +++ b/mobile_app/components/nodes/PendingCosigns.tsx @@ -3,8 +3,7 @@ import { NativeScrollEvent, NativeSyntheticEvent, ScrollView, StyleSheet, Text, View, Pressable, useWindowDimensions, } from 'react-native'; -import { Feather } from '@expo/vector-icons'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { fontFamily, useTheme } from '@/theme'; import { useGlass } from '@/hooks/useGlass'; @@ -54,7 +53,7 @@ export const PendingCosigns = memo(function PendingCosigns({ items, onSign, onRe const isEmpty = items.length === 0; return ( - + {/* Section header */} PENDING CO-SIGNS diff --git a/mobile_app/components/wallet/TxDetailModal.tsx b/mobile_app/components/wallet/TxDetailModal.tsx index d356735e..70fc6209 100644 --- a/mobile_app/components/wallet/TxDetailModal.tsx +++ b/mobile_app/components/wallet/TxDetailModal.tsx @@ -1,15 +1,16 @@ import * as Clipboard from "expo-clipboard"; -import * as WebBrowser from "expo-web-browser"; -import React from "react"; -import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import React, { useEffect, useRef, useState } from "react"; +import { Linking, Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { DepthButton, Icon, Pill } from "@/components/primitives"; +import { Icon, Pill } from "@/components/primitives"; import type { ActivityEntry } from "@/src/services/walletData"; import * as haptics from "@/src/design-system/haptics"; import { buildDevnetExplorerTxUrl } from "@/src/services/explorer"; import { fontFamily as FF, useTheme } from "@/theme"; +const COPY_FEEDBACK_MS = 1400; + interface TxDetailModalProps { readonly tx: ActivityEntry | null; readonly visible: boolean; @@ -45,13 +46,34 @@ function DetailRow({ readonly copyValue?: string; }) { const { colors } = useTheme(); + const [copied, setCopied] = useState(false); + const resetTimer = useRef | null>(null); + + useEffect(() => () => { + if (resetTimer.current !== null) clearTimeout(resetTimer.current); + }, []); async function handleCopy() { if (!copyValue) return; haptics.tap(); - await Clipboard.setStringAsync(copyValue); + try { + await Clipboard.setStringAsync(copyValue); + setCopied(true); + // Clear any in-flight reset before scheduling the next one. Without this, + // rapid re-taps stack timers and an earlier one can flip `copied` back to + // false before the latest tap's window finishes. + if (resetTimer.current !== null) clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => { + resetTimer.current = null; + setCopied(false); + }, COPY_FEEDBACK_MS); + } catch { + setCopied(false); + } } + const iconColor = copied ? colors.primary : colors.textTertiary; + return ( {label} - + {value} - {copyValue ? : null} + {copyValue ? : null} ); @@ -76,16 +98,28 @@ export function TxDetailModal({ tx, visible, onClose }: TxDetailModalProps) { if (!tx) return null; const activeTx = tx; - async function handleExplorer() { + function handleExplorer() { haptics.tap(); - await WebBrowser.openBrowserAsync(buildDevnetExplorerTxUrl(activeTx.signature)); + Linking.openURL(buildDevnetExplorerTxUrl(activeTx.signature)).catch(() => undefined); } return ( - - - + {/* Bottom-sheet split pattern: dismissArea is a flex:1 Pressable that + fills only the space above the sheet (column flex with + justifyContent:flex-end). Sheet has no parent Pressable so nested + action buttons receive presses without responder competition. */} + + + @@ -120,8 +154,31 @@ export function TxDetailModal({ tx, visible, onClose }: TxDetailModalProps) { - - + [ + S.actionBtn, + S.actionBtnSecondary, + { borderColor: colors.border, backgroundColor: colors.surface1 }, + pressed && { opacity: 0.7 }, + ]} + > + Explorer + + [ + S.actionBtn, + { backgroundColor: colors.primary }, + pressed && { opacity: 0.85 }, + ]} + > + Close + @@ -131,11 +188,14 @@ export function TxDetailModal({ tx, visible, onClose }: TxDetailModalProps) { } const S = StyleSheet.create({ - backdrop: { + root: { backgroundColor: "rgba(0,0,0,0.68)", flex: 1, justifyContent: "flex-end", }, + dismissArea: { + flex: 1, + }, sheet: { borderTopLeftRadius: 22, borderTopRightRadius: 22, @@ -222,7 +282,21 @@ const S = StyleSheet.create({ flexDirection: "row", gap: 10, }, - actionButton: { + actionBtn: { + alignItems: "center", + borderRadius: 12, flex: 1, + justifyContent: "center", + minHeight: 44, + paddingHorizontal: 16, + paddingVertical: 12, + }, + actionBtnSecondary: { + borderWidth: 1, + }, + actionBtnText: { + fontFamily: FF.sansSb, + fontSize: 14, + letterSpacing: 0.3, }, }); diff --git a/mobile_app/screens/WalletScreen.tsx b/mobile_app/screens/WalletScreen.tsx index 9eee6711..80983aac 100644 --- a/mobile_app/screens/WalletScreen.tsx +++ b/mobile_app/screens/WalletScreen.tsx @@ -9,13 +9,14 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { ReceivePanel } from '@/components/wallet/ReceivePanel'; +import { TxDetailModal } from '@/components/wallet/TxDetailModal'; import { PendingCosigns, type PendingCosign } from '@/components/nodes/PendingCosigns'; import { useLxmfContext } from '@/context/LxmfContext'; import { useWallet } from '@/context/WalletContext'; import { useHideBalance } from '@/src/hooks/useHideBalance'; import { useWalletBalance } from '@/src/hooks/useWalletBalance'; import { useNetworkMode } from '@/src/hooks/useNetworkMode'; -import type { TokenBalance } from '@/src/services/walletData'; +import type { ActivityEntry, TokenBalance } from '@/src/services/walletData'; import { fontFamily, useTheme } from '@/theme'; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -158,6 +159,7 @@ function ActivityTile({ refreshing, onRefresh }: { readonly refreshing: boolean; const { colors } = useTheme(); const { hidden } = useHideBalance(); const { activity, activityLoading, activityError, lastFetched } = useWalletBalance(); + const [selectedTx, setSelectedTx] = useState(null); const initialLoad = activityLoading && lastFetched === null; return ( @@ -194,15 +196,29 @@ function ActivityTile({ refreshing, onRefresh }: { readonly refreshing: boolean; const out = tx.direction === 'send'; const color = out ? '#FF6B6B' : '#14F195'; const sign = out ? '−' : '+'; - const amount = hidden ? '•••' : `${sign}${tx.amountSol.toFixed(4)}`; + const amount = hidden ? '•••' : `${sign}${fmtAmount(tx.amountSol, tx.decimals)}`; const fallback = out ? 'Sent' : 'Received'; const label = tx.counterparty ? `${tx.counterparty.slice(0, 4)}…${tx.counterparty.slice(-4)}` : fallback; return ( - { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); + setSelectedTx(tx); + }} + style={({ pressed }) => [ + S.activityRow, + i < activity.length - 1 && { borderBottomWidth: 0.5, borderBottomColor: colors.borderSubtle }, + pressed && { opacity: 0.6 }, + ]} + accessibilityRole="button" + accessibilityLabel={ + hidden + ? `${out ? 'Sent' : 'Received'} transaction, amount hidden. Tap for transaction details.` + : `${out ? 'Sent' : 'Received'} ${fmtAmount(tx.amountSol, tx.decimals)} ${tx.symbol}. Tap for transaction details.` + } > @@ -213,12 +229,17 @@ function ActivityTile({ refreshing, onRefresh }: { readonly refreshing: boolean; {amount} - SOL + {tx.symbol} - + ); })} + setSelectedTx(null)} + /> ); } diff --git a/mobile_app/src/services/walletData.ts b/mobile_app/src/services/walletData.ts index 6ffc8971..675815ac 100644 --- a/mobile_app/src/services/walletData.ts +++ b/mobile_app/src/services/walletData.ts @@ -134,6 +134,7 @@ export interface ActivityEntry { amountBaseUnits: string; amountLamports?: number; amountSol: number; + decimals: number; symbol: string; mintAddress?: string; counterparty: string; @@ -314,6 +315,7 @@ function toActivity( status: failed ? "Failed" : "Settled", amountBaseUnits: amountAbs.toString(), amountSol: Number(amountAbs) / Math.pow(10, tokenDelta.decimals), + decimals: tokenDelta.decimals, symbol: tokenDelta.symbol, mintAddress: tokenDelta.mint, counterparty, @@ -336,6 +338,7 @@ function toActivity( amountBaseUnits: String(transfer.lamports), amountLamports: transfer.lamports, amountSol: transfer.lamports / LAMPORTS_PER_SOL, + decimals: SOL_DECIMALS, symbol: "SOL", counterparty, createdAt,