Skip to content

Paul/nicer multiout #5572

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 23, 2025
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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

- added: Add eCash.
- added: Toast notifications for PIN changes.
- changed: Auto launch QR scanner for multi-out payments if previously used
- changed: Auto populate amount for multi-out payments if prior amounts are similar
- changed: Auto scroll to end of `SendScene2` when addresses or amounts change
- fixed: No longer show FIO onboarding modal while in Duress Mode.
- fixed: Prevent pin changes which match duress pin.

Expand All @@ -15,10 +18,9 @@
- added: `Edge-ucation` section in `WalletDetailsScene`
- added: Multiple memo support to `SendScene2`
- added: Add os version to the "OS" line of logs
- added: Informative fiat currency support errors when requesting Moonpay quotes
- added: Informative fiat currency support errors when requesting Moonpay quotes
- changed: "MAX" button in `FlipInput` restyled to tertiary and moved next to the text input field
- changed: Moved spending limits settings to password-locked account settings section.
- fixed: Incorrect type assumptions between `amountQuotePlugin` and `pluginUtils` for error parsing
- fixed: Primary button transforming on the animation on `GettingStartedScene`
- fixed: `NotificationCenterScene` could crash if there was an undismissed new token notification and the associated wallet no longer existed
- removed: Disable Fantom transaction list
Expand Down
10 changes: 10 additions & 0 deletions src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ exports[`SendScene2 1 spendTarget 1`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down Expand Up @@ -1605,6 +1606,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down Expand Up @@ -3229,6 +3231,7 @@ exports[`SendScene2 2 spendTargets 1`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down Expand Up @@ -4811,6 +4814,7 @@ exports[`SendScene2 2 spendTargets hide tiles 1`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down Expand Up @@ -6052,6 +6056,7 @@ exports[`SendScene2 2 spendTargets hide tiles 2`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down Expand Up @@ -7269,6 +7274,7 @@ exports[`SendScene2 2 spendTargets hide tiles 3`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down Expand Up @@ -8304,6 +8310,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down Expand Up @@ -9661,6 +9668,7 @@ exports[`SendScene2 2 spendTargets lock tiles 2`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down Expand Up @@ -10976,6 +10984,7 @@ exports[`SendScene2 2 spendTargets lock tiles 3`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down Expand Up @@ -12225,6 +12234,7 @@ exports[`SendScene2 Render SendScene 1`] = `
extraScrollHeight={62}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
innerRef={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardSpace={0}
Expand Down
66 changes: 58 additions & 8 deletions src/components/scenes/SendScene2.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { add, div, gte, lt, mul } from 'biggystring'
import { abs, add, div, gte, lt, lte, mul, sub } from 'biggystring'
import {
asMaybeInsufficientFundsError,
asMaybeNoAmountSpecifiedError,
Expand Down Expand Up @@ -62,13 +62,15 @@ import { ExchangedFlipInputAmounts, ExchangeFlipInputFields } from '../themed/Ex
import { PinDots } from '../themed/PinDots'
import { SafeSlider } from '../themed/SafeSlider'
import { SelectFioAddress2 } from '../themed/SelectFioAddress2'
import { AddressTile2, ChangeAddressResult } from '../tiles/AddressTile2'
import { AddressEntryMethod, AddressTile2, ChangeAddressResult } from '../tiles/AddressTile2'
import { CountdownTile } from '../tiles/CountdownTile'
import { EditableAmountTile } from '../tiles/EditableAmountTile'
import { ErrorTile } from '../tiles/ErrorTile'

// TODO: Check contentPadding

const SCROLL_TO_END_DELAY_MS = 150

interface Props extends EdgeAppSceneProps<'send2'> {}

export interface SendScene2Params {
Expand Down Expand Up @@ -110,12 +112,15 @@ interface FioSenderInfo {
skipRecord?: boolean
}

// TODO: For now, do not allow multiple targets to be added via GUI. UX is very poor until
// animation is added. Waiting for reanimated v3 which fixes crashes in Layout animations.
// Note: multiple targets can be added via JSON payment protocol to fix payments to Anypay
// invoices.
const ALLOW_MULTIPLE_TARGETS = true

/**
* If the prior two spend targets of a multi-out payment have the same amount
* within 0.5%, then use the same amount for the new spend target.
* This makes it MUCH easier to load many gift cards without having to enter
* amounts manually.
*/
const MULTI_OUT_DIFF_PERCENT = '0.005'
const PIN_MAX_LENGTH = 4
const INFINITY_STRING = '999999999999999999999999999999999999999'

Expand All @@ -125,7 +130,9 @@ const SendComponent = (props: Props) => {
const theme = useTheme()
const styles = getStyles(theme)

const needsScrollToEnd = React.useRef<boolean>(false)
const makeSpendCounter = React.useRef<number>(0)
const scrollViewRef = React.useRef<KeyboardAwareScrollView | null>(null)

const initialMount = React.useRef<boolean>(true)
const pinInputRef = React.useRef<TextInput>(null)
Expand Down Expand Up @@ -161,6 +168,7 @@ const SendComponent = (props: Props) => {
const [edgeTransaction, setEdgeTransaction] = useState<EdgeTransaction | null>(null)
const [pinValue, setPinValue] = useState<string | undefined>(undefined)
const [spendingLimitExceeded, setSpendingLimitExceeded] = useState<boolean>(false)
const [lastAddressEntryMethod, setLastAddressEntryMethod] = useState<AddressEntryMethod | undefined>(undefined)
const [fioSender, setFioSender] = useState<FioSenderInfo>({
fioAddress: fioPendingRequest?.payer_fio_address ?? '',
fioWallet: null,
Expand Down Expand Up @@ -232,7 +240,7 @@ const SendComponent = (props: Props) => {
const handleChangeAddress =
(spendTarget: EdgeSpendTarget) =>
async (changeAddressResult: ChangeAddressResult): Promise<void> => {
const { parsedUri, fioAddress } = changeAddressResult
const { addressEntryMethod, parsedUri, fioAddress } = changeAddressResult

if (parsedUri != null) {
if (parsedUri.metadata != null) {
Expand All @@ -241,21 +249,41 @@ const SendComponent = (props: Props) => {
spendTarget.uniqueIdentifier = parsedUri?.uniqueIdentifier
spendTarget.publicAddress = parsedUri?.publicAddress
spendTarget.nativeAmount = parsedUri?.nativeAmount

if (spendInfo.spendTargets.length > 2 && spendTarget.nativeAmount == null) {
// Check if the last two spend targets have the same amount within 0.5%
const prevAmount = spendInfo.spendTargets[spendInfo.spendTargets.length - 2].nativeAmount
const pprevAmount = spendInfo.spendTargets[spendInfo.spendTargets.length - 3].nativeAmount

if (prevAmount != null && pprevAmount != null) {
const diff = abs(sub(prevAmount, pprevAmount))
const diffPercent = div(diff, prevAmount, DECIMAL_PRECISION)
if (lte(diffPercent, MULTI_OUT_DIFF_PERCENT)) {
spendTarget.nativeAmount = prevAmount
}
}
}
spendTarget.otherParams = {
fioAddress
}

// We can assume the spendTarget object came from the Component spendInfo so simply resetting the spendInfo
// should properly re-render with new spendTargets
setLastAddressEntryMethod(addressEntryMethod)
setMinNativeAmount(parsedUri.minNativeAmount)
setExpireDate(parsedUri?.expireDate)
setSpendInfo({ ...spendInfo })
needsScrollToEnd.current = true
}
}

const handleAddressAmountPress = (index: number) => () => {
// This is deleting the combo address/amount tile. If this happens, remove the
// lastAddressEntryMethod so we don't auto launch the camera again.
setLastAddressEntryMethod(undefined)
spendInfo.spendTargets.splice(index, 1)
setSpendInfo({ ...spendInfo })
needsScrollToEnd.current = true
}

const renderAddressAmountTile = (index: number, spendTarget: EdgeSpendTarget) => {
Expand Down Expand Up @@ -292,6 +320,9 @@ const SendComponent = (props: Props) => {
setExpireDate(undefined)
setPinValue(undefined)
setSpendInfo({ ...spendInfo })
// This is deleting the amount tile. If this happens, remove the
// lastAddressEntryMethod so we don't auto launch the camera again.
setLastAddressEntryMethod(undefined)
}

const renderAddressTile = (index: number, spendTarget: EdgeSpendTarget) => {
Expand All @@ -300,7 +331,7 @@ const SendComponent = (props: Props) => {
const { publicAddress = '', otherParams = {} } = spendTarget
const { fioAddress } = otherParams
const title = lstrings.send_scene_send_to_address + (spendInfo.spendTargets.length > 1 ? ` ${(index + 1).toString()}` : '')
const doOpenCamera = openCameraRef.current
const doOpenCamera = openCameraRef.current || (publicAddress === '' && lastAddressEntryMethod === 'scan')
if (openCameraRef.current) openCameraRef.current = false

return (
Expand Down Expand Up @@ -332,6 +363,7 @@ const SendComponent = (props: Props) => {
setSpendInfo({ ...spendInfo })
setMaxSpendSetter(-1)
setFieldChanged(newField)
needsScrollToEnd.current = true
}

const handleSetMax = (index: number) => () => {
Expand Down Expand Up @@ -457,6 +489,7 @@ const SendComponent = (props: Props) => {
const handleAddAddress = useHandler(() => {
spendInfo.spendTargets.push({})
setSpendInfo({ ...spendInfo })
needsScrollToEnd.current = true
})

const renderAddAddress = () => {
Expand Down Expand Up @@ -1114,6 +1147,19 @@ const SendComponent = (props: Props) => {
backgroundColors[0] = scaledColor
}

React.useEffect(() => {
// Hack: While you would think to use InteractionManager.runAfterInteractions,
// it doesn't work because several renders occur before the full height is
// determined and the scrollToEnd call would be effective.
const timeout = setTimeout(() => {
if (needsScrollToEnd.current) {
scrollViewRef.current?.scrollToEnd(true)
needsScrollToEnd.current = false
}
}, SCROLL_TO_END_DELAY_MS)
return () => clearTimeout(timeout)
})

return (
<SceneWrapper
hasNotifications
Expand All @@ -1127,6 +1173,10 @@ const SendComponent = (props: Props) => {
{({ insetStyle }) => (
<>
<StyledKeyboardAwareScrollView
innerRef={ref => {
const kbRef: KeyboardAwareScrollView | null = ref as any
scrollViewRef.current = kbRef
}}
contentContainerStyle={{
...insetStyle,
paddingTop: 0,
Expand Down
17 changes: 10 additions & 7 deletions src/components/tiles/AddressTile2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ import { Airship, showError, showToast } from '../services/AirshipInstance'
import { cacheStyles, Theme, useTheme } from '../services/ThemeContext'
import { EdgeText } from '../themed/EdgeText'

export type AddressEntryMethod = 'scan' | 'other'

export interface ChangeAddressResult {
fioAddress?: string
parsedUri?: EdgeParsedUri
addressEntryMethod: AddressEntryMethod
}

export interface AddressTileRef {
Expand Down Expand Up @@ -90,7 +93,7 @@ export const AddressTile2 = React.forwardRef((props: Props, ref: React.Forwarded
// Handlers
// ---------------------------------------------------------------------------

const changeAddress = useHandler(async (address: string) => {
const changeAddress = useHandler(async (address: string, addressEntryMethod: AddressEntryMethod) => {
if (address == null || address === '') return

setLoading(true)
Expand Down Expand Up @@ -164,7 +167,7 @@ export const AddressTile2 = React.forwardRef((props: Props, ref: React.Forwarded
}

// set address
await onChangeAddress({ fioAddress, parsedUri })
await onChangeAddress({ fioAddress, parsedUri, addressEntryMethod })
} catch (e: any) {
const currencyInfo = coreWallet.currencyInfo
const ercTokenStandard = currencyInfo.defaultSettings?.otherSettings?.ercTokenStandard ?? ''
Expand Down Expand Up @@ -196,7 +199,7 @@ export const AddressTile2 = React.forwardRef((props: Props, ref: React.Forwarded
try {
// Will throw in case uri is invalid
await coreWallet.parseUri(clipboard, currencyCode)
await changeAddress(clipboard)
await changeAddress(clipboard, 'other')
} catch (error) {
showError(error, { trackError: false })
}
Expand All @@ -216,7 +219,7 @@ export const AddressTile2 = React.forwardRef((props: Props, ref: React.Forwarded
))
.then(async (result: string | undefined) => {
if (result) {
await changeAddress(result)
await changeAddress(result, 'scan')
}
})
.catch(error => {
Expand All @@ -230,7 +233,7 @@ export const AddressTile2 = React.forwardRef((props: Props, ref: React.Forwarded
))
.then(async result => {
if (result) {
await changeAddress(result)
await changeAddress(result, 'other')
}
})
.catch(error => {
Expand Down Expand Up @@ -263,7 +266,7 @@ export const AddressTile2 = React.forwardRef((props: Props, ref: React.Forwarded
// Prefer segwit address if the selected wallet has one
const { segwitAddress, publicAddress } = await wallet.getReceiveAddress({ tokenId: null })
const address = segwitAddress != null ? segwitAddress : publicAddress
await changeAddress(address)
await changeAddress(address, 'other')
})
.catch(err => showError(err))
})
Expand All @@ -282,7 +285,7 @@ export const AddressTile2 = React.forwardRef((props: Props, ref: React.Forwarded

React.useImperativeHandle(ref, () => ({
async onChangeAddress(address: string) {
await changeAddress(address)
await changeAddress(address, 'other')
}
}))

Expand Down
Loading