diff --git a/mobile/app/Home.tsx b/mobile/app/Home.tsx index a3073532..f26c9581 100644 --- a/mobile/app/Home.tsx +++ b/mobile/app/Home.tsx @@ -11,6 +11,7 @@ import { scheduleOnRN } from 'react-native-worklets'; import ActionButtons, { Action } from '@/components/ActionButtons'; import BackupWarning from '@/components/BackupWarning'; import Balance from '@/components/Balance'; +import ReceiveCta from '@/components/ReceiveCta'; import DashboardTiles, { LayerCard } from '@/components/DashboardTiles'; import { McpAgentDashboard } from '@/src/features/mcp/components/McpAgentDashboard'; import { McpTunnelStatusRow } from '@/src/features/mcp/components/McpTunnelStatusRow'; @@ -40,6 +41,7 @@ import { sleep } from '@shared/modules/sleep'; import { capitalizeFirstLetter } from '@shared/modules/string-utils'; import { CommonTransaction } from '@shared/types/common-transaction'; import { NETWORK_ARK, NETWORK_LIGHTNING_TESTNET, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_SPARK } from '@shared/types/networks'; +import { STORAGE_KEY_RECEIVE_CTA } from '@shared/types/IStorage'; import { CachedTokenInfo } from '@shared/types/token-info'; import { YieldBearingCachedTokenInfo } from '@shared/hooks/useYieldDiscovery'; import { OnrampProps } from './Onramp'; @@ -78,6 +80,18 @@ export default function Home() { const settingsContext = useSettings(); const hasBackedUpSeed = settingsContext.settings.seedBackedUp === 'ON'; + // New-user "receive bitcoin" CTA — flag set only on wallet creation, cleared on dismiss/Receive. + const [showReceiveCta, setShowReceiveCta] = useState(false); + + useEffect(() => { + LayerzStorage.getItem(STORAGE_KEY_RECEIVE_CTA).then((value) => setShowReceiveCta(!!value)); + }, []); + + const dismissReceiveCta = useCallback(() => { + setShowReceiveCta(false); + LayerzStorage.setItem(STORAGE_KEY_RECEIVE_CTA, ''); + }, []); + const handleFund = useCallback(() => { BackgroundExecutor.getAddress(network, accountNumber).then((address) => { const onrampParams: OnrampProps = { address, network }; @@ -366,7 +380,10 @@ export default function Home() { {accountNumber === MCP_BALANCE_ACCOUNT_NUMBER ? : null} {/* Action Buttons Section (hidden on MCP automation account) */} - {accountNumber !== MCP_BALANCE_ACCOUNT_NUMBER ? : null} + {accountNumber !== MCP_BALANCE_ACCOUNT_NUMBER ? : null} + + {/* New-user "receive bitcoin" CTA — caption below the action buttons */} + {showReceiveCta && accountNumber !== MCP_BALANCE_ACCOUNT_NUMBER ? : null} {/* Seed Backup Warning */} {hasBackedUpSeed === false && } diff --git a/mobile/components/ActionButtons.tsx b/mobile/components/ActionButtons.tsx index e5d35f47..24116c7c 100644 --- a/mobile/components/ActionButtons.tsx +++ b/mobile/components/ActionButtons.tsx @@ -27,9 +27,13 @@ export const Action = ({ network, text }: { network?: Networks; text: string }) interface ActionButtonsProps { onFundPress: () => void; + /** When true, draws a breathing glow on the Receive button (new-user CTA). */ + highlightReceive?: boolean; + /** Called when the user engages the Receive button — used to dismiss the new-user CTA. */ + onReceivePress?: () => void; } -export default function ActionButtons({ onFundPress }: ActionButtonsProps) { +export default function ActionButtons({ onFundPress, highlightReceive = false, onReceivePress }: ActionButtonsProps) { const router = useRouter(); const { network } = useContext(NetworkContext); @@ -47,6 +51,7 @@ export default function ActionButtons({ onFundPress }: ActionButtonsProps) { }; const handleReceive = () => { + onReceivePress?.(); router.push('/Receive'); }; @@ -55,6 +60,7 @@ export default function ActionButtons({ onFundPress }: ActionButtonsProps) { }; const handleReceiveOnLightningAddress = () => { + onReceivePress?.(); router.push('/ReceiveOnLightningAddress'); }; @@ -122,18 +128,18 @@ export default function ActionButtons({ onFundPress }: ActionButtonsProps) { const renderReceiveButton = () => { if (network === NETWORK_LIGHTNING || network === NETWORK_LIGHTNING_TESTNET) { // Default to Lightning Address receive on tap (as per master behavior) - return ; + return ; } if (network === NETWORK_USDT) { return ( - {}} testID="ReceiveButton" /> + onReceivePress?.()} glow={highlightReceive} testID="ReceiveButton" /> ); } - return ; + return ; }; // Render Fund button (only if canBuyWithFiat is true) diff --git a/mobile/components/HomeActionButton.tsx b/mobile/components/HomeActionButton.tsx index f07e9190..1037971e 100644 --- a/mobile/components/HomeActionButton.tsx +++ b/mobile/components/HomeActionButton.tsx @@ -1,9 +1,17 @@ import { Ionicons, MaterialIcons } from '@expo/vector-icons'; -import React from 'react'; +import React, { useEffect } from 'react'; import { ActivityIndicator, StyleProp, StyleSheet, TextStyle, View, ViewStyle } from 'react-native'; +import Animated, { cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated'; import Pressable, { PressableProps } from './Pressable'; import { ThemedText } from './ThemedText'; +// Bitcoin orange — warm accent that stays visible on every network background gradient. +const GLOW_COLOR = '#F7931A'; +// Breathing glow opacity range — floors above 0 so the halo stays continuously visible. +const GLOW_MIN = 0.3; +const GLOW_MAX = 0.62; +const GLOW_DURATION = 2400; // ms per half-cycle — slow, calm breathing + type IconConfig = | { name: React.ComponentProps['name']; type: 'material'; size?: number } | { name: React.ComponentProps['name']; type?: 'ionicons'; size?: number }; @@ -14,9 +22,39 @@ export interface HomeActionButtonProps extends PressableProps { variant?: 'light' | 'dark'; loading?: boolean; textStyle?: TextStyle; + /** When true, renders a soft breathing glow halo behind the button to draw attention. */ + glow?: boolean; } -export default function HomeActionButton({ title, icon, onPress, variant = 'light', disabled = false, loading = false, style, textStyle, activeOpacity = 0.8, ...restProps }: HomeActionButtonProps) { +export default function HomeActionButton({ + title, + icon, + onPress, + variant = 'light', + disabled = false, + loading = false, + style, + textStyle, + activeOpacity = 0.8, + glow = false, + ...restProps +}: HomeActionButtonProps) { + const glowOpacity = useSharedValue(0); + + useEffect(() => { + if (glow) { + // Start dim, then repeat reversed so opacity oscillates GLOW_MIN <-> GLOW_MAX forever. + glowOpacity.value = GLOW_MIN; + glowOpacity.value = withRepeat(withTiming(GLOW_MAX, { duration: GLOW_DURATION, easing: Easing.inOut(Easing.ease) }), -1, true); + } else { + cancelAnimation(glowOpacity); + glowOpacity.value = withTiming(0, { duration: 300 }); + } + return () => cancelAnimation(glowOpacity); + }, [glow, glowOpacity]); + + const glowStyle = useAnimatedStyle(() => ({ opacity: glowOpacity.value })); + const getButtonStyle = (): StyleProp => { const baseStyle: ViewStyle[] = [styles.button]; @@ -66,6 +104,7 @@ export default function HomeActionButton({ title, icon, onPress, variant = 'ligh return ( + {glow ? : null} {renderContent()} @@ -88,6 +127,20 @@ const styles = StyleSheet.create({ buttonWrapper: { width: '100%', }, + glow: { + position: 'absolute', + top: -9, + left: -9, + right: -9, + bottom: -9, + borderRadius: 36, + backgroundColor: GLOW_COLOR, + shadowColor: GLOW_COLOR, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.9, + shadowRadius: 14, + elevation: 10, + }, button: { width: '100%', height: 54, diff --git a/mobile/components/ReceiveCta.tsx b/mobile/components/ReceiveCta.tsx new file mode 100644 index 00000000..7cd8de55 --- /dev/null +++ b/mobile/components/ReceiveCta.tsx @@ -0,0 +1,76 @@ +import { Ionicons } from '@expo/vector-icons'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; + +import Pressable from '@/components/Pressable'; +import { ThemedText } from '@/components/ThemedText'; + +export interface ReceiveCtaProps { + onDismiss: () => void; +} + +// Bitcoin orange — keep in sync with the Receive-button glow in HomeActionButton. +const ACCENT = '#F7931A'; +const FADE_OUT_DURATION = 260; + +// One-time CTA nudging brand-new (created, not imported) wallets to receive their first bitcoin. +const ReceiveCta: React.FC = ({ onDismiss }) => { + const opacity = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + // Fade out first, then notify the parent to remove it and persist the dismissed flag. + const handleDismiss = () => { + opacity.value = withTiming(0, { duration: FADE_OUT_DURATION }, (finished) => { + if (finished) { + scheduleOnRN(onDismiss); + } + }); + }; + + return ( + + + + Start by receiving bitcoin + + + + + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginTop: -18, + marginBottom: 24, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + arrow: { + marginTop: 1, + }, + text: { + fontSize: 14, + fontWeight: '500', + color: 'rgba(255, 255, 255, 0.9)', + }, + closeButton: { + position: 'absolute', + right: 0, + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default ReceiveCta; diff --git a/mobile/src/modules/background-executor.ts b/mobile/src/modules/background-executor.ts index d25a5eea..8d590f9d 100644 --- a/mobile/src/modules/background-executor.ts +++ b/mobile/src/modules/background-executor.ts @@ -18,7 +18,15 @@ import { clearWalletCache, } from '@shared/modules/wallet-utils'; import { IBackgroundCaller, OpenPopupRequest } from '@shared/types/IBackgroundCaller'; -import { ENCRYPTED_PREFIX, STORAGE_KEY_ACCEPTED_TOS, STORAGE_KEY_EVM_XPUB, STORAGE_KEY_MNEMONIC, STORAGE_KEY_WHITELIST, STORAGE_KEY_SEED_VERIFIED } from '@shared/types/IStorage'; +import { + ENCRYPTED_PREFIX, + STORAGE_KEY_ACCEPTED_TOS, + STORAGE_KEY_EVM_XPUB, + STORAGE_KEY_MNEMONIC, + STORAGE_KEY_RECEIVE_CTA, + STORAGE_KEY_WHITELIST, + STORAGE_KEY_SEED_VERIFIED, +} from '@shared/types/IStorage'; import { Networks, NETWORK_ARK, NETWORK_ARK_MUTINYNET, NETWORK_BITCOIN, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_SPARK, NETWORK_STACKS } from '@shared/types/networks'; import { BrowserBridge } from '../class/browser-bridge'; import { LayerzStorage } from '../class/layerz-storage'; @@ -163,6 +171,8 @@ export const BackgroundExecutor: IBackgroundCaller = { await LayerzStorage.setItem(STORAGE_KEY_EVM_XPUB, xpub); await LayerzStorage.setItem(STORAGE_KEY_EVM_XPUB, xpub); await saveBitcoinXpubs(LayerzStorage, mnemonic); + // Freshly created (not imported) wallet: flag the home screen to show the one-time "receive bitcoin" CTA. + await LayerzStorage.setItem(STORAGE_KEY_RECEIVE_CTA, 'true'); return { mnemonic }; }, diff --git a/shared/types/IStorage.ts b/shared/types/IStorage.ts index 2d589838..5ba59b84 100644 --- a/shared/types/IStorage.ts +++ b/shared/types/IStorage.ts @@ -14,6 +14,7 @@ export const STORAGE_KEY_SYMBIOSIS_TRANSFERS = 'STORAGE_KEY_SYMBIOSIS_TRANSFERS' export const STORAGE_KEY_FLASHNET_TRANSFERS = 'STORAGE_KEY_FLASHNET_TRANSFERS'; export const STORAGE_KEY_SPARK_REFUNDED_DEPOSITS = 'STORAGE_KEY_SPARK_REFUNDED_DEPOSITS'; export const STORAGE_KEY_SPARK_LN_INVOICE_IDS = 'STORAGE_KEY_SPARK_LN_INVOICE_IDS'; +export const STORAGE_KEY_RECEIVE_CTA = 'STORAGE_KEY_RECEIVE_CTA'; export interface IStorage { setItem(key: string, value: string): Promise;