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
91 changes: 91 additions & 0 deletions __tests__/offlineSendSuggestions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
buildExactOfflineAmountIndex,
getOfflineSendSuggestions,
} from '@/features/send/lib/offlineSendSuggestions';

function createProofService(exactAmounts: number[], readyProofAmounts: number[]) {
const exactAmountSet = new Set(exactAmounts);

return {
async getReadyProofs() {
return readyProofAmounts.map((amount) => ({ amount }));
},
async selectProofsToSend(_mintUrl: string, amount: number) {
return exactAmountSet.has(amount) ? [{ amount }] : [{ amount: amount + 1 }];
},
};
}

describe('offline send suggestions', () => {
it('builds a reusable exact-send index from available proofs', () => {
const result = buildExactOfflineAmountIndex([2, 4, 8]);

expect(result.totalReadyBalance).toBe(14);
expect(result.reachableSums).toEqual([2, 4, 6, 8, 10, 12, 14]);
});

it('recognizes when the requested amount is already sendable offline', async () => {
const proofService = createProofService([6, 8, 10, 12, 14], [2, 4, 8]);

const result = await getOfflineSendSuggestions(proofService, 'mint-a', 6);

expect(result).toEqual({
isRequestedAmountSendableOffline: true,
roundDownAmount: null,
roundUpAmount: null,
totalReadyBalance: 14,
});
});

it('finds both round-down and round-up options around a swap-required amount', async () => {
const proofService = createProofService([6, 8, 10, 12, 14], [2, 4, 8]);

const result = await getOfflineSendSuggestions(proofService, 'mint-a', 7);

expect(result).toEqual({
isRequestedAmountSendableOffline: false,
roundDownAmount: 6,
roundUpAmount: 8,
totalReadyBalance: 14,
});
});

it('returns only a lower option when the requested amount is above every exact sendable amount', async () => {
const proofService = createProofService([6, 8, 10, 12, 14], [2, 4, 8]);

const result = await getOfflineSendSuggestions(proofService, 'mint-a', 15);

expect(result).toEqual({
isRequestedAmountSendableOffline: false,
roundDownAmount: 14,
roundUpAmount: null,
totalReadyBalance: 14,
});
});

it('returns only a higher option when the requested amount is below every exact sendable amount', async () => {
const proofService = createProofService([4, 8, 12], [4, 8]);

const result = await getOfflineSendSuggestions(proofService, 'mint-a', 1);

expect(result).toEqual({
isRequestedAmountSendableOffline: false,
roundDownAmount: null,
roundUpAmount: 4,
totalReadyBalance: 12,
});
});

it('returns no suggestions when no exact offline amount can be validated', async () => {
const proofService = createProofService([], [2, 4, 8]);

const result = await getOfflineSendSuggestions(proofService, 'mint-a', 7);

expect(result).toEqual({
isRequestedAmountSendableOffline: false,
roundDownAmount: null,
roundUpAmount: null,
totalReadyBalance: 14,
});
});
});
197 changes: 96 additions & 101 deletions app/(drawer)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { BackgroundProvider } from '@/shared/providers/BackgroundProvider';
import { DynamicColorIOS, Platform, StyleSheet, View } from 'react-native';
import { useEffect } from 'react';
import { IconSymbol } from '@/shared/ui/primitives/icon-symbol';
import { OfflineProvider } from '@/shared/providers/OfflineProvider';
import {
GlobalLiquidGlassTabsOverlay,
isLiquidGlassTabBarAvailable,
Expand Down Expand Up @@ -34,118 +33,114 @@ export default function TabLayout() {
if (isExpo55NativeTabsSupported()) {
return (
<BackgroundProvider>
<OfflineProvider>
<View style={{ flex: 1 }}>
<Expo55NativeTabs
labelStyle={{
color: Platform.select({
ios: DynamicColorIOS({
dark: '#ECEDEE',
light: '#11181C',
}),
}),
}}
tintColor={Platform.select({
<View style={{ flex: 1 }}>
<Expo55NativeTabs
labelStyle={{
color: Platform.select({
ios: DynamicColorIOS({
dark: '#fff',
light: '#0a7ea4',
dark: '#ECEDEE',
light: '#11181C',
}),
})}
disableTransparentOnScrollEdge>
<Expo55NativeTabs.Trigger name="feed">
<Expo55NativeTabs.Trigger.Icon sf={{ default: 'house', selected: 'house.fill' }} />
<Expo55NativeTabs.Trigger.Label>Feed</Expo55NativeTabs.Trigger.Label>
</Expo55NativeTabs.Trigger>
}),
}}
tintColor={Platform.select({
ios: DynamicColorIOS({
dark: '#fff',
light: '#0a7ea4',
}),
})}
disableTransparentOnScrollEdge>
<Expo55NativeTabs.Trigger name="feed">
<Expo55NativeTabs.Trigger.Icon sf={{ default: 'house', selected: 'house.fill' }} />
<Expo55NativeTabs.Trigger.Label>Feed</Expo55NativeTabs.Trigger.Label>
</Expo55NativeTabs.Trigger>

<Expo55NativeTabs.Trigger name="payments">
<Expo55NativeTabs.Trigger.Icon
sf={{
default: 'arrow.up.arrow.down',
selected: 'arrow.up.arrow.down',
}}
/>
<Expo55NativeTabs.Trigger.Label>Contacts</Expo55NativeTabs.Trigger.Label>
</Expo55NativeTabs.Trigger>
<Expo55NativeTabs.Trigger name="payments">
<Expo55NativeTabs.Trigger.Icon
sf={{
default: 'arrow.up.arrow.down',
selected: 'arrow.up.arrow.down',
}}
/>
<Expo55NativeTabs.Trigger.Label>Contacts</Expo55NativeTabs.Trigger.Label>
</Expo55NativeTabs.Trigger>

<Expo55NativeTabs.Trigger name="index">
<Expo55NativeTabs.Trigger.Icon
sf={{
default: 'wallet.bifold',
selected: 'wallet.bifold',
}}
/>
<Expo55NativeTabs.Trigger.Label>Wallet</Expo55NativeTabs.Trigger.Label>
</Expo55NativeTabs.Trigger>
<Expo55NativeTabs.Trigger name="index">
<Expo55NativeTabs.Trigger.Icon
sf={{
default: 'wallet.bifold',
selected: 'wallet.bifold',
}}
/>
<Expo55NativeTabs.Trigger.Label>Wallet</Expo55NativeTabs.Trigger.Label>
</Expo55NativeTabs.Trigger>

<Expo55NativeTabs.Trigger name="explore">
<Expo55NativeTabs.Trigger.Icon
sf={{ default: 'paperplane', selected: 'paperplane.fill' }}
/>
<Expo55NativeTabs.Trigger.Label>Explore</Expo55NativeTabs.Trigger.Label>
</Expo55NativeTabs.Trigger>
</Expo55NativeTabs>
</View>
</OfflineProvider>
<Expo55NativeTabs.Trigger name="explore">
<Expo55NativeTabs.Trigger.Icon
sf={{ default: 'paperplane', selected: 'paperplane.fill' }}
/>
<Expo55NativeTabs.Trigger.Label>Explore</Expo55NativeTabs.Trigger.Label>
</Expo55NativeTabs.Trigger>
</Expo55NativeTabs>
</View>
</BackgroundProvider>
);
}

// Fallback for pre-iOS 26 and Android
return (
<BackgroundProvider>
<OfflineProvider>
<View style={{ flex: 1 }}>
<Tabs
initialRouteName="index"
screenOptions={{
headerShown: false,
...(!hasAndroidLiquidGlass && { tabBarBackground: () => <TabBarBackground /> }),
tabBarStyle: hasAndroidLiquidGlass
? { display: 'none' }
: {
position: 'absolute',
backgroundColor: 'transparent',
borderTopColor: 'transparent',
elevation: 0,
},
tabBarActiveTintColor: '#fff',
tabBarInactiveTintColor: '#ECEDEE',
}}>
<Tabs.Screen
name="feed"
options={{
title: 'Feed',
tabBarIcon: ({ color }) => <IconSymbol name="house" color={color} size={24} />,
}}
/>
<Tabs.Screen
name="payments"
options={{
title: 'Payments',
tabBarIcon: ({ color }) => (
<IconSymbol name="arrow.up.arrow.down" color={color} size={24} />
),
}}
/>
<Tabs.Screen
name="index"
options={{
title: 'Wallet',
tabBarIcon: ({ color }) => (
<IconSymbol name="wallet.bifold" color={color} size={24} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
...(hasAndroidLiquidGlass ? {} : { href: null }),
}}
/>
</Tabs>
{hasAndroidLiquidGlass ? <GlobalLiquidGlassTabsOverlay /> : null}
</View>
</OfflineProvider>
<View style={{ flex: 1 }}>
<Tabs
initialRouteName="index"
screenOptions={{
headerShown: false,
...(!hasAndroidLiquidGlass && { tabBarBackground: () => <TabBarBackground /> }),
tabBarStyle: hasAndroidLiquidGlass
? { display: 'none' }
: {
position: 'absolute',
backgroundColor: 'transparent',
borderTopColor: 'transparent',
elevation: 0,
},
tabBarActiveTintColor: '#fff',
tabBarInactiveTintColor: '#ECEDEE',
}}>
<Tabs.Screen
name="feed"
options={{
title: 'Feed',
tabBarIcon: ({ color }) => <IconSymbol name="house" color={color} size={24} />,
}}
/>
<Tabs.Screen
name="payments"
options={{
title: 'Payments',
tabBarIcon: ({ color }) => (
<IconSymbol name="arrow.up.arrow.down" color={color} size={24} />
),
}}
/>
<Tabs.Screen
name="index"
options={{
title: 'Wallet',
tabBarIcon: ({ color }) => (
<IconSymbol name="wallet.bifold" color={color} size={24} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
...(hasAndroidLiquidGlass ? {} : { href: null }),
}}
/>
</Tabs>
{hasAndroidLiquidGlass ? <GlobalLiquidGlassTabsOverlay /> : null}
</View>
</BackgroundProvider>
);
}
20 changes: 14 additions & 6 deletions app/(send-flow)/currency.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { WalletHeaderTitle } from '@/features/wallet';
import Icon from 'assets/icons';
import { useMintStore } from '@/shared/stores/profile/mintStore';
import { useNostrKeysContext } from '@/shared/providers/NostrKeysProvider';
import { useOfflineStatus } from '@/shared/providers/OfflineProvider';
import { useThemeColor } from '@/shared/hooks/useThemeColor';
import {
getHeaderTitleWidth,
Expand Down Expand Up @@ -40,6 +41,7 @@ function ModalScreen() {
}>();

const { keys } = useNostrKeysContext();
const { isOffline } = useOfflineStatus();
const foreground = useThemeColor('foreground');
const selectedMints = useMintStore((state) => state.selectedMints);
const selectedMint = keys?.pubkey ? selectedMints[keys.pubkey] : undefined;
Expand All @@ -63,23 +65,29 @@ function ModalScreen() {
const handlePressSendModeInfo = useCallback(() => {
if (!sendMode) {
Alert.alert(
'Checking route',
'Determining whether this send can go offline (no swap) or needs an online swap.'
isOffline ? 'Checking offline route' : 'Checking route',
isOffline
? 'Determining whether this send can be completed offline or if it would need a swap once you reconnect.'
: 'Determining whether this send can go offline (no swap) or needs an online swap.'
);
return;
}
if (sendMode === 'offline') {
Alert.alert(
'Offline send',
'This amount can be sent directly with existing proofs, so no swap is required.'
isOffline
? 'This amount can be sent right now while offline because your existing proofs already match it exactly.'
: 'This amount can be sent directly with existing proofs, so no swap is required.'
);
return;
}
Alert.alert(
'Online send',
'This amount requires a mint swap to construct the exact send proofs before sending.'
isOffline ? 'Offline round required' : 'Online send',
isOffline
? 'This amount needs a mint swap, so while offline you will be asked to round up or down to a nearby exact sendable amount.'
: 'This amount requires a mint swap to construct the exact send proofs before sending.'
);
}, [sendMode]);
}, [isOffline, sendMode]);

return (
<>
Expand Down
Loading