diff --git a/eslint.config.mjs b/eslint.config.mjs index fb0252d2c08..e5b0f29cc00 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -107,7 +107,7 @@ export default [ 'src/actions/TransactionExportActions.tsx', 'src/actions/WalletActions.tsx', 'src/actions/WalletListActions.tsx', - 'src/actions/WalletListMenuActions.tsx', + 'src/app.ts', 'src/components/buttons/ButtonsView.tsx', 'src/components/buttons/EdgeSwitch.tsx', @@ -202,7 +202,6 @@ export default [ 'src/components/modals/SwapVerifyTermsModal.tsx', 'src/components/modals/TextInputModal.tsx', 'src/components/modals/TransferModal.tsx', - 'src/components/modals/WalletListMenuModal.tsx', 'src/components/modals/WalletListSortModal.tsx', 'src/components/modals/WcSmartContractModal.tsx', diff --git a/src/actions/WalletListMenuActions.tsx b/src/actions/WalletListMenuActions.tsx index 5dd611b7315..034386c1888 100644 --- a/src/actions/WalletListMenuActions.tsx +++ b/src/actions/WalletListMenuActions.tsx @@ -31,6 +31,7 @@ import { toggleUserPausedWallet } from './SettingsActions' export type WalletListMenuKey = | 'settings' + | 'pocketChange' | 'rename' | 'delete' | 'resync' @@ -65,6 +66,11 @@ export function walletListMenuAction( }) } } + case 'pocketChange': { + return async () => { + // Handled directly in WalletListMenuModal via Airship + } + } case 'manageTokens': { return async (dispatch, getState) => { navigation.navigate('manageTokens', { @@ -79,7 +85,7 @@ export function walletListMenuAction( const { account } = state.core account .changeWalletStates({ [walletId]: { deleted: true } }) - .catch(error => { + .catch((error: unknown) => { showError(error) }) } @@ -118,8 +124,8 @@ export function walletListMenuAction( try { const fioAddresses = await engine.otherMethods.getFioAddressNames() - fioAddress = fioAddresses.length ? fioAddresses[0] : '' - } catch (e: any) { + fioAddress = fioAddresses.length > 0 ? fioAddresses[0] : '' + } catch (e: unknown) { fioAddress = '' } } @@ -129,7 +135,7 @@ export function walletListMenuAction( let additionalMsg: string | undefined let tokenCurrencyCode: string | undefined if (tokenId == null) { - if (fioAddress) { + if (fioAddress !== '') { additionalMsg = lstrings.fragmet_wallets_delete_fio_extra_message_mobile } else if (Object.keys(wallet.currencyConfig.allTokens).length > 0) { @@ -155,7 +161,7 @@ export function walletListMenuAction( )} ${wallet.type} ${wallet.id}` ) }) - .catch(error => { + .catch((error: unknown) => { showError(error) }) @@ -176,7 +182,7 @@ export function walletListMenuAction( } ${tokenId}` ) }) - .catch(error => { + .catch((error: unknown) => { showError(error) }) } @@ -297,8 +303,8 @@ export function walletListMenuAction( ) // Add a copy button only for development let devButtons = {} - // @ts-expect-error - if (global.__DEV__) + // @ts-expect-error - __DEV__ is a RN global not in TS types + if (global.__DEV__ === true) devButtons = { copy: { label: lstrings.fragment_wallets_copy_seed } } @@ -313,8 +319,8 @@ export function walletListMenuAction( buttons={{ ok: { label: lstrings.string_ok_cap }, ...devButtons }} /> )).then(buttonPressed => { - // @ts-expect-error - if (global.__DEV__ && buttonPressed === 'copy') { + // @ts-expect-error - __DEV__ is a RN global not in TS types + if (global.__DEV__ === true && buttonPressed === 'copy') { Clipboard.setString(privateKey) showToast(lstrings.fragment_wallets_copied_seed) } diff --git a/src/components/modals/PocketChangeModal.tsx b/src/components/modals/PocketChangeModal.tsx new file mode 100644 index 00000000000..dabf7b2181d --- /dev/null +++ b/src/components/modals/PocketChangeModal.tsx @@ -0,0 +1,125 @@ +import * as React from 'react' +import { View } from 'react-native' +import type { AirshipBridge } from 'react-native-airship' + +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { EdgeButton } from '../buttons/EdgeButton' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { SettingsHeaderRow } from '../settings/SettingsHeaderRow' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' +import { EdgeText, Paragraph } from '../themed/EdgeText' +import { EdgeModal } from './EdgeModal' + +const POCKET_AMOUNTS_XMR = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3] + +export interface PocketChangeConfig { + enabled: boolean + amountIndex: number +} + +interface Props { + bridge: AirshipBridge + initialConfig: PocketChangeConfig +} + +export const PocketChangeModal: React.FC = props => { + const { bridge, initialConfig } = props + const theme = useTheme() + const styles = getStyles(theme) + const [enabled, setEnabled] = React.useState(initialConfig.enabled) + const [amountIndex, setAmountIndex] = React.useState( + initialConfig.amountIndex + ) + + const handleCancel = useHandler((): void => { + bridge.resolve(undefined) + }) + + const handleSave = useHandler((): void => { + bridge.resolve({ enabled, amountIndex }) + }) + + const handleToggle = useHandler((): void => { + setEnabled(prev => !prev) + }) + + const handleDecrease = useHandler((): void => { + setAmountIndex(prev => Math.max(0, prev - 1)) + }) + + const handleIncrease = useHandler((): void => { + setAmountIndex(prev => Math.min(POCKET_AMOUNTS_XMR.length - 1, prev + 1)) + }) + + return ( + + + {lstrings.pocketchange_description} + + + + {enabled ? ( + <> + + + + + + {POCKET_AMOUNTS_XMR[amountIndex]} XMR{' '} + {lstrings.pocketchange_per_pocket} + + = POCKET_AMOUNTS_XMR.length - 1} + /> + + + {lstrings.pocketchange_explainer} + + ) : null} + + + + + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + paddingHorizontal: theme.rem(0.5) + }, + stepperRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: theme.rem(0.5) + }, + amountText: { + fontSize: theme.rem(1.1), + marginHorizontal: theme.rem(1) + }, + saveButton: { + marginTop: theme.rem(1), + marginBottom: theme.rem(0.5) + } +})) diff --git a/src/components/modals/WalletListMenuModal.tsx b/src/components/modals/WalletListMenuModal.tsx index e755b5b6e74..d3634b67a40 100644 --- a/src/components/modals/WalletListMenuModal.tsx +++ b/src/components/modals/WalletListMenuModal.tsx @@ -26,11 +26,12 @@ import { import { getWalletName } from '../../util/CurrencyWalletHelpers' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { CryptoIcon } from '../icons/CryptoIcon' -import { showError } from '../services/AirshipInstance' +import { Airship, showError } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { UnscaledText } from '../text/UnscaledText' import { ModalTitle } from '../themed/ModalParts' import { EdgeModal } from './EdgeModal' +import { type PocketChangeConfig, PocketChangeModal } from './PocketChangeModal' interface Option { value: WalletListMenuKey @@ -53,6 +54,7 @@ const icons: Record = { getSeed: 'key', goToParent: 'upcircleo', manageTokens: 'plus', + pocketChange: 'wallet', rawDelete: 'warning', rename: 'edit', resync: 'sync', @@ -75,6 +77,11 @@ export const WALLET_LIST_MENU: Array<{ label: lstrings.settings_asset_settings, value: 'settings' }, + { + pluginIds: ['monero'], + label: lstrings.pocketchange_menu_item, + value: 'pocketChange' + }, { label: lstrings.string_rename, value: 'rename' @@ -142,7 +149,7 @@ export const WALLET_LIST_MENU: Array<{ } ] -export function WalletListMenuModal(props: Props) { +export function WalletListMenuModal(props: Props): React.ReactElement { const { bridge, tokenId, navigation, walletId } = props const [options, setOptions] = React.useState([]) @@ -161,13 +168,41 @@ export function WalletListMenuModal(props: Props) { const theme = useTheme() const styles = getStyles(theme) - const handleCancel = () => { + const handleCancel = (): void => { props.bridge.resolve() } const optionAction = useHandler(async (option: WalletListMenuKey) => { if (loadingOption != null) return // Prevent multiple actions + if (option === 'pocketChange' && wallet != null) { + setLoadingOption(option) + try { + let initialConfig: PocketChangeConfig = { + enabled: false, + amountIndex: 2 + } + if (wallet.otherMethods?.getPocketChangeSetting != null) { + const saved = await wallet.otherMethods.getPocketChangeSetting() + if (saved != null) initialConfig = saved + } + const result = await Airship.show(b => ( + + )) + if ( + result != null && + wallet.otherMethods?.setPocketChangeSetting != null + ) { + await wallet.otherMethods.setPocketChangeSetting(result) + } + bridge.resolve() + } catch (error) { + setLoadingOption(null) + showError(error) + } + return + } + setLoadingOption(option) try { await dispatch( diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index c417bd5c84c..4944068fc65 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -260,6 +260,10 @@ const SendComponent = (props: Props): React.ReactElement => { // -1 = no max spend, otherwise equal to the index the spendTarget that requested the max spend. const [maxSpendSetter, setMaxSpendSetter] = useState(-1) + const [pocketChangeTargets, setPocketChangeTargets] = useState< + EdgeSpendTarget[] + >([]) + const countryCode = useSelector(state => state.ui.countryCode) const account = useSelector(state => state.core.account) const exchangeRates = useSelector( @@ -1110,6 +1114,29 @@ const SendComponent = (props: Props): React.ReactElement => { ) } + const renderPocketChange = (): React.ReactElement | null => { + if (pocketChangeTargets.length === 0) return null + + return ( + <> + {pocketChangeTargets.map((target, i) => { + const displayAmount = div( + target.nativeAmount ?? '0', + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return ( + + ) + })} + + ) + } + const renderScamWarning = (): React.ReactElement | null => { const { publicAddress } = spendInfo.spendTargets[0] @@ -1581,9 +1608,32 @@ const SendComponent = (props: Props): React.ReactElement => { } } + // Expand spend targets with pocket change outputs if supported + let finalSpendInfo = spendInfo + if (coreWallet.otherMethods?.getPocketChangeTargetsForSpend != null) { + try { + const expanded = + await coreWallet.otherMethods.getPocketChangeTargetsForSpend( + spendInfo.spendTargets + ) + const pockets = expanded.filter( + (t: EdgeSpendTarget) => t.otherParams?.isPocketChange === true + ) + setPocketChangeTargets(pockets) + if (expanded.length > spendInfo.spendTargets.length) { + finalSpendInfo = { ...spendInfo, spendTargets: expanded } + } + } catch (e: unknown) { + console.log('Pocket change expansion skipped:', e) + setPocketChangeTargets([]) + } + } else { + setPocketChangeTargets([]) + } + makeSpendCounter.current++ const localMakeSpendCounter = makeSpendCounter.current - const edgeTx = await coreWallet.makeSpend(spendInfo) + const edgeTx = await coreWallet.makeSpend(finalSpendInfo) if (localMakeSpendCounter < makeSpendCounter.current) { // This makeSpend result is out of date. Throw it away since a newer one is in flight. // This is not REALLY needed since useAsyncEffect seems to serialize calls into the effect @@ -1770,6 +1820,7 @@ const SendComponent = (props: Props): React.ReactElement => { {renderFees()} + {renderPocketChange()} {renderMetadataNotes()} {renderMemoOptions()} {renderInfoTiles()} diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index e274ddf1b8f..584c3e18c8c 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -2497,7 +2497,22 @@ const strings = { 'Please ensure all details are correct before making the transfer.', // #endregion - unknown_error_message: 'An unknown error occurred.' + unknown_error_message: 'An unknown error occurred.', + + // #region Pocket Change + pocketchange_title: 'Pocket Change', + pocketchange_description: + 'Pocket Change pre-splits your balance into smaller outputs for faster spending. Each "pocket" can be spent independently without waiting for change.', + pocketchange_enable: 'Enable Pocket Change', + pocketchange_amount_header: 'Pocket Size', + pocketchange_amount_label: 'Amount per pocket', + pocketchange_per_pocket: 'per pocket', + pocketchange_explainer: + 'When enabled, received funds will automatically be split into pockets of the selected size.', + pocketchange_menu_item: 'Pocket Change', + pocketchange_line_item: 'Pocket Change', + pocketchange_save: 'Save' + // #endregion } as const export default strings diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index d3ce2839f27..dab0e5695e9 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1947,5 +1947,15 @@ "ramp_account_number_label": "Account Number", "ramp_routing_number_label": "Routing Number", "ramp_bank_routing_warning": "Please ensure all details are correct before making the transfer.", - "unknown_error_message": "An unknown error occurred." + "unknown_error_message": "An unknown error occurred.", + "pocketchange_title": "Pocket Change", + "pocketchange_description": "Pocket Change pre-splits your balance into smaller outputs for faster spending. Each \"pocket\" can be spent independently without waiting for change.", + "pocketchange_enable": "Enable Pocket Change", + "pocketchange_amount_header": "Pocket Size", + "pocketchange_amount_label": "Amount per pocket", + "pocketchange_per_pocket": "per pocket", + "pocketchange_explainer": "When enabled, received funds will automatically be split into pockets of the selected size.", + "pocketchange_menu_item": "Pocket Change", + "pocketchange_line_item": "Pocket Change", + "pocketchange_save": "Save" }