Skip to content
Open
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
19 changes: 18 additions & 1 deletion mobile/app/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -366,7 +380,10 @@ export default function Home() {
{accountNumber === MCP_BALANCE_ACCOUNT_NUMBER ? <McpAgentDashboard /> : null}

{/* Action Buttons Section (hidden on MCP automation account) */}
{accountNumber !== MCP_BALANCE_ACCOUNT_NUMBER ? <ActionButtons onFundPress={handleFund} /> : null}
{accountNumber !== MCP_BALANCE_ACCOUNT_NUMBER ? <ActionButtons onFundPress={handleFund} highlightReceive={showReceiveCta} onReceivePress={dismissReceiveCta} /> : null}

{/* New-user "receive bitcoin" CTA — caption below the action buttons */}
{showReceiveCta && accountNumber !== MCP_BALANCE_ACCOUNT_NUMBER ? <ReceiveCta onDismiss={dismissReceiveCta} /> : null}

{/* Seed Backup Warning */}
{hasBackedUpSeed === false && <BackupWarning onPress={handleBackupSeed} />}
Expand Down
14 changes: 10 additions & 4 deletions mobile/components/ActionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -47,6 +51,7 @@ export default function ActionButtons({ onFundPress }: ActionButtonsProps) {
};

const handleReceive = () => {
onReceivePress?.();
router.push('/Receive');
};

Expand All @@ -55,6 +60,7 @@ export default function ActionButtons({ onFundPress }: ActionButtonsProps) {
};

const handleReceiveOnLightningAddress = () => {
onReceivePress?.();
router.push('/ReceiveOnLightningAddress');
};

Expand Down Expand Up @@ -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 <HomeActionButton title="Receive" icon={{ name: 'call-received', type: 'material', size: 24 }} onPress={handleReceiveOnLightningAddress} testID="ReceiveButton" />;
return <HomeActionButton title="Receive" icon={{ name: 'call-received', type: 'material', size: 24 }} onPress={handleReceiveOnLightningAddress} glow={highlightReceive} testID="ReceiveButton" />;
}

if (network === NETWORK_USDT) {
return (
<ActionPopupButton actions={usdtReceiveActions} title="Layer to receive">
<HomeActionButton title="Receive" icon={{ name: 'call-received', type: 'material', size: 24 }} onPress={() => {}} testID="ReceiveButton" />
<HomeActionButton title="Receive" icon={{ name: 'call-received', type: 'material', size: 24 }} onPress={() => onReceivePress?.()} glow={highlightReceive} testID="ReceiveButton" />
</ActionPopupButton>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch only dismisses the CTA. It never pushes /Receive or /ReceiveOnLightningAddress, so USDT users get a button that eats their tap and goes nowhere.

);
}

return <HomeActionButton title="Receive" icon={{ name: 'call-received', type: 'material', size: 24 }} onPress={handleReceive} testID="ReceiveButton" />;
return <HomeActionButton title="Receive" icon={{ name: 'call-received', type: 'material', size: 24 }} onPress={handleReceive} glow={highlightReceive} testID="ReceiveButton" />;
};

// Render Fund button (only if canBuyWithFiat is true)
Expand Down
57 changes: 55 additions & 2 deletions mobile/components/HomeActionButton.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof MaterialIcons>['name']; type: 'material'; size?: number }
| { name: React.ComponentProps<typeof Ionicons>['name']; type?: 'ionicons'; size?: number };
Expand All @@ -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<ViewStyle> => {
const baseStyle: ViewStyle[] = [styles.button];

Expand Down Expand Up @@ -66,6 +104,7 @@ export default function HomeActionButton({ title, icon, onPress, variant = 'ligh
return (
<View style={styles.buttonContainer}>
<View style={styles.buttonWrapper}>
{glow ? <Animated.View pointerEvents="none" style={[styles.glow, glowStyle]} /> : null}
<Pressable style={getButtonStyle()} onPress={onPress} disabled={disabled || loading} activeOpacity={activeOpacity} {...restProps}>
<View style={[styles.surface, surfaceStyle]}>{renderContent()}</View>
</Pressable>
Expand All @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions mobile/components/ReceiveCta.tsx
Original file line number Diff line number Diff line change
@@ -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<ReceiveCtaProps> = ({ 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 (
<Animated.View style={[styles.wrapper, animatedStyle]} testID="ReceiveCta">
<View style={styles.row}>
<Ionicons name="arrow-up" size={15} color={ACCENT} style={styles.arrow} />
<ThemedText style={styles.text}>Start by receiving bitcoin</ThemedText>
</View>
<Pressable onPress={handleDismiss} hitSlop={10} style={styles.closeButton} testID="ReceiveCtaClose">
<Ionicons name="close" size={16} color="rgba(255, 255, 255, 0.7)" />
</Pressable>
</Animated.View>
);
};

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;
12 changes: 11 additions & 1 deletion mobile/src/modules/background-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
},
Expand Down
1 change: 1 addition & 0 deletions shared/types/IStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down
Loading