Skip to content
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

Refactor Fiat Plugin enterAmount API #4186

Merged
merged 10 commits into from May 18, 2023
147 changes: 80 additions & 67 deletions src/plugins/gui/amountQuotePlugin.ts
Expand Up @@ -12,7 +12,7 @@ import { getTokenId } from '../../util/CurrencyInfoHelpers'
import { fetchInfo } from '../../util/network'
import { logEvent } from '../../util/tracking'
import { fuzzyTimeout } from '../../util/utils'
import { FiatPlugin, FiatPluginFactory, FiatPluginFactoryArgs, FiatPluginGetMethodsResponse, FiatPluginStartParams } from './fiatPluginTypes'
import { FiatPlugin, FiatPluginFactory, FiatPluginFactoryArgs, FiatPluginStartParams } from './fiatPluginTypes'
import { FiatProvider, FiatProviderAssetMap, FiatProviderGetQuoteParams, FiatProviderQuote } from './fiatProviderTypes'
import { createStore, getBestError, getRateFromQuote } from './pluginUtils'
import { banxaProvider } from './providers/banxaProvider'
Expand Down Expand Up @@ -139,6 +139,7 @@ export const amountQuoteFiatPlugin: FiatPluginFactory = async (params: FiatPlugi
let counter = 0
let bestQuote: FiatProviderQuote | undefined
let goodQuotes: FiatProviderQuote[] = []
let lastSourceFieldNum: number

// HACK: Force EUR for sepa Bity, since Bity doesn't accept USD, a common
// wallet fiat selection regardless of region.
Expand All @@ -147,26 +148,37 @@ export const amountQuoteFiatPlugin: FiatPluginFactory = async (params: FiatPlugi
const displayFiatCurrencyCode = fiatCurrencyCode.replace('iso:', '')
const isBuy = direction === 'buy'

let enterAmountMethods: FiatPluginGetMethodsResponse
logEvent(isBuy ? 'Buy_Quote' : 'Sell_Quote')

// Navigate to scene to have user enter amount
showUi.enterAmount({
headerTitle: isBuy ? sprintf(lstrings.fiat_plugin_buy_currencycode, currencyCode) : sprintf(lstrings.fiat_plugin_sell_currencycode_s, currencyCode),
isBuy,
initState: {
value1: '500'
},
label1: sprintf(lstrings.fiat_plugin_amount_currencycode, displayFiatCurrencyCode),
label2: sprintf(lstrings.fiat_plugin_amount_currencycode, currencyCode),
initialAmount1: '500',
getMethods: (methods: FiatPluginGetMethodsResponse) => {
enterAmountMethods = methods
},
convertValue: async (sourceFieldNum: number, value: string): Promise<string | undefined> => {
async onChangeText() {},
async onFieldChange(event) {
const { stateManager } = event
const { sourceFieldNum, value } = event.value
if (!isValidInput(value)) {
if (enterAmountMethods != null)
enterAmountMethods.setStatusText({ statusText: lstrings.create_wallet_invalid_input, options: { textType: 'error' } })
stateManager.update({ statusText: { content: lstrings.create_wallet_invalid_input, textType: 'error' } })
return
}
bestQuote = undefined
goodQuotes = []
if (eq(value, '0')) return ''
lastSourceFieldNum = sourceFieldNum

const otherFieldKey = sourceFieldNum === 1 ? 'value2' : 'value1'
const spinnerKey = sourceFieldNum === 1 ? 'spinner2' : 'spinner1'

stateManager.update({ [spinnerKey]: true })
Copy link
Member

Choose a reason for hiding this comment

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

why is the spinner now getting modified in this callback?


if (eq(value, '0')) {
stateManager.update({ [otherFieldKey]: '', [spinnerKey]: false })
return
}
const myCounter = ++counter
let quoteParams: FiatProviderGetQuoteParams
let sourceFieldCurrencyCode
Expand Down Expand Up @@ -220,76 +232,77 @@ export const amountQuoteFiatPlugin: FiatPluginFactory = async (params: FiatPlugi
if (goodQuotes.length === 0) {
// Find the best error to surface
const bestErrorText = getBestError(errors as any, sourceFieldCurrencyCode) ?? lstrings.fiat_plugin_buy_no_quote
if (enterAmountMethods != null) enterAmountMethods.setStatusText({ statusText: bestErrorText, options: { textType: 'error' } })
stateManager.update({ statusText: { content: bestErrorText, textType: 'error' }, [spinnerKey]: false })
return
}

// Find best quote factoring in pluginPriorities
bestQuote = getBestQuote(goodQuotes, priorityArray ?? [{}])
if (bestQuote == null) {
if (enterAmountMethods != null) enterAmountMethods.setStatusText({ statusText: lstrings.fiat_plugin_buy_no_quote, options: { textType: 'error' } })
stateManager.update({ statusText: { content: lstrings.fiat_plugin_buy_no_quote, textType: 'error' }, [spinnerKey]: false })
return
}

const exchangeRateText = getRateFromQuote(bestQuote, displayFiatCurrencyCode)
if (enterAmountMethods != null) {
const poweredByOnClick = async () => {
// 1. Show modal with all the valid quotes
const items = goodQuotes.map(quote => {
let text
if (sourceFieldNum === 1) {
// User entered a fiat value. Show the crypto value per partner
const localeAmount = formatNumber(toFixed(quote.cryptoAmount, 0, 6))
text = `(${localeAmount} ${quote.displayCurrencyCode})`
} else {
// User entered a crypto value. Show the fiat value per partner
const localeAmount = formatNumber(toFixed(quote.fiatAmount, 0, 2))
text = `(${localeAmount} ${quote.fiatCurrencyCode.replace('iso:', '')})`
}
const out = {
text,
name: quote.pluginDisplayName,
icon: getPartnerIconUri(quote.partnerIcon)
}
return out
})
const rowName = await showUi.listModal({
title: 'Providers',
selected: bestQuote?.pluginDisplayName ?? '',
items
})
if (bestQuote == null) return

// 2. Set the best quote to the one chosen by user (if any is chosen)
if (rowName != null && rowName !== bestQuote.pluginDisplayName) {
bestQuote = goodQuotes.find(quote => quote.pluginDisplayName === rowName)
if (bestQuote == null) return

// 3. Set the status text and powered by
const statusText = getRateFromQuote(bestQuote, displayFiatCurrencyCode)
enterAmountMethods.setStatusText({ statusText })
enterAmountMethods.setPoweredBy({ poweredByText: bestQuote.pluginDisplayName, poweredByIcon: bestQuote.partnerIcon, poweredByOnClick })

logEvent(isBuy ? 'Buy_Quote_Change_Provider' : 'Sell_Quote_Change_Provider')

if (sourceFieldNum === 1) {
enterAmountMethods.setValue2(bestQuote.cryptoAmount)
} else {
enterAmountMethods.setValue1(bestQuote.fiatAmount)
}
}
stateManager.update({
statusText: { content: exchangeRateText },
poweredBy: { poweredByText: bestQuote.pluginDisplayName, poweredByIcon: bestQuote.partnerIcon },
[otherFieldKey]: sourceFieldNum === 1 ? toFixed(bestQuote.cryptoAmount, 0, 6) : toFixed(bestQuote.fiatAmount, 0, 2),
[spinnerKey]: false
})
},
async onPoweredByClick(event) {
const { stateManager } = event
// 1. Show modal with all the valid quotes
const items = goodQuotes.map(quote => {
let text
if (lastSourceFieldNum === 1) {
// User entered a fiat value. Show the crypto value per partner
const localeAmount = formatNumber(toFixed(quote.cryptoAmount, 0, 6))
text = `(${localeAmount} ${quote.displayCurrencyCode})`
} else {
// User entered a crypto value. Show the fiat value per partner
const localeAmount = formatNumber(toFixed(quote.fiatAmount, 0, 2))
text = `(${localeAmount} ${quote.fiatCurrencyCode.replace('iso:', '')})`
}
const out = {
text,
name: quote.pluginDisplayName,
icon: getPartnerIconUri(quote.partnerIcon)
}
return out
})
const rowName = await showUi.listModal({
title: 'Providers',
selected: bestQuote?.pluginDisplayName ?? '',
items
})
if (bestQuote == null) return

// 2. Set the best quote to the one chosen by user (if any is chosen)
if (rowName != null && rowName !== bestQuote.pluginDisplayName) {
bestQuote = goodQuotes.find(quote => quote.pluginDisplayName === rowName)
if (bestQuote == null) return

// 3. Set the status text and powered by
const statusText = getRateFromQuote(bestQuote, displayFiatCurrencyCode)
stateManager.update({
statusText: { content: statusText },
poweredBy: { poweredByText: bestQuote.pluginDisplayName, poweredByIcon: bestQuote.partnerIcon }
})

logEvent(isBuy ? 'Buy_Quote_Change_Provider' : 'Sell_Quote_Change_Provider')

if (lastSourceFieldNum === 1) {
stateManager.update({ value2: bestQuote.cryptoAmount })
} else {
stateManager.update({ value1: bestQuote.fiatAmount })
}

enterAmountMethods.setStatusText({ statusText: exchangeRateText })
enterAmountMethods.setPoweredBy({ poweredByText: bestQuote.pluginDisplayName, poweredByIcon: bestQuote.partnerIcon, poweredByOnClick })
}
if (sourceFieldNum === 1) {
return toFixed(bestQuote.cryptoAmount, 0, 6)
} else {
return toFixed(bestQuote.fiatAmount, 0, 2)
}
},
async onSubmit() {
logEvent(isBuy ? 'Buy_Quote_Next' : 'Sell_Quote_Next')

if (bestQuote == null) {
return
}
Expand Down
27 changes: 4 additions & 23 deletions src/plugins/gui/fiatPlugin.tsx
Expand Up @@ -12,13 +12,10 @@ import { SendScene2Params } from '../../components/scenes/SendScene2'
import { Airship, showError, showToastSpinner } from '../../components/services/AirshipInstance'
import { HomeAddress, SepaInfo } from '../../types/FormTypes'
import { GuiPlugin } from '../../types/GuiPluginTypes'
import { NavigationBase } from '../../types/routerTypes'
import { logEvent } from '../../util/tracking'
import { AppParamList, NavigationBase } from '../../types/routerTypes'
import {
FiatPaymentType,
FiatPluginAddressFormParams,
FiatPluginEnterAmountParams,
FiatPluginEnterAmountResponse,
FiatPluginListModalParams,
FiatPluginRegionCode,
FiatPluginSepaFormParams,
Expand All @@ -39,7 +36,6 @@ export const executePlugin = async (params: {
}): Promise<void> => {
const { disablePlugins = {}, account, direction, guiPlugin, navigation, paymentType, providerId, regionCode } = params
const { pluginId } = guiPlugin
const isBuy = direction === 'buy'

const showUi: FiatPluginUi = {
showToastSpinner,
Expand All @@ -62,23 +58,8 @@ export const executePlugin = async (params: {
))
return result
},
enterAmount(params: FiatPluginEnterAmountParams) {
const { headerTitle, label1, label2, initialAmount1, convertValue, getMethods, onSubmit } = params
logEvent(isBuy ? 'Buy_Quote' : 'Sell_Quote')

navigation.navigate('guiPluginEnterAmount', {
headerTitle,
label1,
label2,
initialAmount1,
getMethods,
convertValue,
onChangeText: async () => undefined,
onSubmit: async (value: FiatPluginEnterAmountResponse) => {
logEvent(isBuy ? 'Buy_Quote_Next' : 'Sell_Quote_Next')
onSubmit(value)
}
})
enterAmount(params: AppParamList['guiPluginEnterAmount']) {
navigation.navigate('guiPluginEnterAmount', params)
},
addressForm: async (params: FiatPluginAddressFormParams) => {
const { countryCode, headerTitle, headerIconUri, onSubmit } = params
Expand Down Expand Up @@ -132,7 +113,7 @@ export const executePlugin = async (params: {
})
})
},
popScene: async () => {
exitScene: async () => {
navigation.pop()
}
}
Expand Down
31 changes: 9 additions & 22 deletions src/plugins/gui/fiatPluginTypes.ts
Expand Up @@ -5,8 +5,9 @@ import { DisablePluginMap } from '../../actions/ExchangeInfoActions'
import { SendScene2Params } from '../../components/scenes/SendScene2'
import { HomeAddress, SepaInfo } from '../../types/FormTypes'
import { GuiPlugin } from '../../types/GuiPluginTypes'
import { AppParamList } from '../../types/routerTypes'
import { EdgeTokenId } from '../../types/types'
import { EnterAmountPoweredBy } from './scenes/FiatPluginEnterAmountScene'
import { StateManager } from './hooks/useStateManager'

export const asFiatDirection = asValue('buy', 'sell')
export type FiatDirection = ReturnType<typeof asFiatDirection>
Expand All @@ -21,25 +22,6 @@ export interface FiatPluginAddressFormParams {
onSubmit: (homeAddress: HomeAddress) => Promise<void>
}

export interface FiatPluginGetMethodsResponse {
setStatusText: (params: { statusText: string; options?: { textType?: 'warning' | 'error' } }) => void
setPoweredBy: (params: EnterAmountPoweredBy) => void
setValue1: (value: string) => void
setValue2: (value: string) => void
}

export interface FiatPluginEnterAmountParams {
headerTitle: string
isBuy: boolean
label1: string
label2: string
convertValue: (sourceFieldNum: number, value: string) => Promise<string | undefined>
onSubmit: (value: FiatPluginEnterAmountResponse) => Promise<void>
getMethods?: (methods: FiatPluginGetMethodsResponse) => void
initialAmount1?: string
headerIconUri?: string
}

export interface FiatPluginSepaFormParams {
headerTitle: string
headerIconUri?: string
Expand Down Expand Up @@ -97,11 +79,11 @@ export interface FiatPluginUi {
}>
showError: (error: Error) => Promise<void>
listModal: (params: FiatPluginListModalParams) => Promise<string | undefined>
enterAmount: (params: FiatPluginEnterAmountParams) => void
enterAmount: (params: AppParamList['guiPluginEnterAmount']) => void
addressForm: (params: FiatPluginAddressFormParams) => Promise<HomeAddress>
sepaForm: (params: FiatPluginSepaFormParams) => Promise<SepaInfo>
sepaTransferInfo: (params: FiatPluginSepaTransferParams) => Promise<void>
popScene: () => {}
exitScene: () => {}
send: (params: SendScene2Params) => Promise<void>
// showWebView: (params: { webviewUrl: string }) => Promise<void>
}
Expand Down Expand Up @@ -133,3 +115,8 @@ export interface FiatPlugin {
}

export type FiatPluginFactory = (params: FiatPluginFactoryArgs) => Promise<FiatPlugin>

export interface StatefulSceneEvent<EventValue, State extends object> {
Copy link
Member

Choose a reason for hiding this comment

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

I won't ask for a change, but not sure what this commit buys us. The syntax for calling the callbacks is more complex. The generics also make the code harder to read.

value: EventValue
stateManager: StateManager<State>
}
31 changes: 31 additions & 0 deletions src/plugins/gui/hooks/useStateManager.ts
@@ -0,0 +1,31 @@
import { useRef } from 'react'

import { useHandler } from '../../../hooks/useHandler'
import { useState } from '../../../types/reactHooks'

export class StateManager<State extends object> {
Copy link
Member

Choose a reason for hiding this comment

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

why the class? Why not just instantiate an object within useRef on line 28?

private _state: State
private readonly onUpdate: (state: State) => void

constructor(state: State, onUpdate: (state: State) => void) {
this._state = state
this.onUpdate = onUpdate
}

get state(): State {
return this._state
}

update(state: Partial<State>): void {
this._state = { ...this._state, ...state }
this.onUpdate(this._state)
}
}

export const useStateManager = <T extends object>(defaultState: T): StateManager<T> => {
const [state, setState] = useState<T>(defaultState)
const handleUpdate: StateManager<T>['update'] = useHandler((state: Partial<T>) => setState({ ...stateManagerRef.current.state, ...state }))
const stateManagerRef = useRef<StateManager<T>>(new StateManager(state, handleUpdate))

return stateManagerRef.current
}
4 changes: 2 additions & 2 deletions src/plugins/gui/providers/bityProvider.ts
Expand Up @@ -441,7 +441,7 @@ export const bityProvider: FiatProviderFactory = {
await completeSellOrder(approveQuoteRes, coreWallet, showUi)
}

showUi.popScene()
showUi.exitScene()
}
})
},
Expand Down Expand Up @@ -522,7 +522,7 @@ const completeBuyOrder = async (approveQuoteRes: BityApproveQuoteResponse, showU
}
},
onDone: async () => {
showUi.popScene()
showUi.exitScene()
}
})
}
Expand Down