wallet-stack 1.0.0-alpha.134 → 1.0.0-alpha.136
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/identity/actions.ts +10 -0
- package/src/identity/contactMapping.test.ts +5 -0
- package/src/identity/contactMapping.ts +5 -0
- package/src/identity/reducer.test.ts +29 -0
- package/src/identity/reducer.ts +15 -0
- package/src/identity/saga.ts +2 -2
- package/src/identity/selectors.ts +2 -0
- package/src/redux/migrations.ts +7 -0
- package/src/redux/store.test.ts +2 -1
- package/src/redux/store.ts +1 -1
- package/src/send/EnterAmount.tsx +106 -83
- package/src/send/SendEnterAmount.test.tsx +27 -0
- package/src/send/SendSelectRecipient.test.tsx +42 -2
- package/src/send/SendSelectRecipient.tsx +10 -11
- package/src/send/useFetchRecipientVerificationStatus.test.tsx +131 -0
- package/src/send/useFetchRecipientVerificationStatus.ts +9 -1
package/package.json
CHANGED
package/src/identity/actions.ts
CHANGED
|
@@ -15,6 +15,7 @@ export enum Actions {
|
|
|
15
15
|
CANCEL_IMPORT_CONTACTS = 'IDENTITY/CANCEL_IMPORT_CONTACTS',
|
|
16
16
|
END_IMPORT_CONTACTS = 'IDENTITY/END_IMPORT_CONTACTS',
|
|
17
17
|
FETCH_ADDRESS_VERIFICATION_STATUS = 'IDENTITY/FETCH_ADDRESS_VERIFICATION_STATUS',
|
|
18
|
+
RECIPIENT_LOOKUP_RESOLVED = 'IDENTITY/RECIPIENT_LOOKUP_RESOLVED',
|
|
18
19
|
CONTACTS_SAVED = 'IDENTITY/CONTACTS_SAVED',
|
|
19
20
|
STORED_PASSWORD_REFRESHED = 'IDENTITY/STORED_PASSWORD_REFRESHED',
|
|
20
21
|
}
|
|
@@ -57,6 +58,10 @@ export interface FetchAddressVerificationAction {
|
|
|
57
58
|
address: string
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
interface RecipientLookupResolvedAction {
|
|
62
|
+
type: Actions.RECIPIENT_LOOKUP_RESOLVED
|
|
63
|
+
}
|
|
64
|
+
|
|
60
65
|
interface ContactsSavedAction {
|
|
61
66
|
type: Actions.CONTACTS_SAVED
|
|
62
67
|
hash: string
|
|
@@ -74,6 +79,7 @@ export type ActionTypes =
|
|
|
74
79
|
| EndImportContactsAction
|
|
75
80
|
| FetchAddressesAndValidateAction
|
|
76
81
|
| FetchAddressVerificationAction
|
|
82
|
+
| RecipientLookupResolvedAction
|
|
77
83
|
| ContactsSavedAction
|
|
78
84
|
| StoredPasswordRefreshedAction
|
|
79
85
|
|
|
@@ -87,6 +93,10 @@ export const fetchAddressVerification = (address: string): FetchAddressVerificat
|
|
|
87
93
|
address,
|
|
88
94
|
})
|
|
89
95
|
|
|
96
|
+
export const recipientLookupResolved = (): RecipientLookupResolvedAction => ({
|
|
97
|
+
type: Actions.RECIPIENT_LOOKUP_RESOLVED,
|
|
98
|
+
})
|
|
99
|
+
|
|
90
100
|
export const updateE164PhoneNumberAddresses = (
|
|
91
101
|
e164NumberToAddress: E164NumberToAddressType,
|
|
92
102
|
addressToE164Number: AddressToE164NumberType,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
contactsSaved,
|
|
16
16
|
fetchAddressVerification,
|
|
17
17
|
fetchAddressesAndValidate,
|
|
18
|
+
recipientLookupResolved,
|
|
18
19
|
updateE164PhoneNumberAddresses,
|
|
19
20
|
} from 'src/identity/actions'
|
|
20
21
|
import {
|
|
@@ -118,6 +119,7 @@ describe('Fetch Addresses Saga', () => {
|
|
|
118
119
|
{}
|
|
119
120
|
)
|
|
120
121
|
)
|
|
122
|
+
.put(recipientLookupResolved())
|
|
121
123
|
.run()
|
|
122
124
|
|
|
123
125
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
@@ -228,6 +230,7 @@ describe('Fetch Addresses Saga', () => {
|
|
|
228
230
|
[call(retrieveSignedMessage), 'some signed message'],
|
|
229
231
|
])
|
|
230
232
|
.put(showErrorOrFallback(expect.anything(), ErrorMessages.ADDRESS_LOOKUP_FAILURE))
|
|
233
|
+
.put(recipientLookupResolved())
|
|
231
234
|
.run()
|
|
232
235
|
})
|
|
233
236
|
})
|
|
@@ -249,6 +252,7 @@ describe('Fetch Address Verification Saga', () => {
|
|
|
249
252
|
[call(retrieveSignedMessage), 'some signed message'],
|
|
250
253
|
])
|
|
251
254
|
.put(updateE164PhoneNumberAddresses({}, {}, { [mockAccount.toLowerCase()]: 'minipay' }))
|
|
255
|
+
.put(recipientLookupResolved())
|
|
252
256
|
.run()
|
|
253
257
|
|
|
254
258
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
@@ -296,6 +300,7 @@ describe('Fetch Address Verification Saga', () => {
|
|
|
296
300
|
])
|
|
297
301
|
.not.put.actionType(Actions.UPDATE_E164_PHONE_NUMBER_ADDRESSES)
|
|
298
302
|
.put(showErrorOrFallback(expect.anything(), ErrorMessages.ADDRESS_LOOKUP_FAILURE))
|
|
303
|
+
.put(recipientLookupResolved())
|
|
299
304
|
.run()
|
|
300
305
|
expect(AppAnalytics.track).toHaveBeenCalledWith(IdentityEvents.address_lookup_error, {
|
|
301
306
|
error: 'Unable to fetch verification status for this address',
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
FetchAddressesAndValidateAction,
|
|
14
14
|
contactsSaved,
|
|
15
15
|
endImportContacts,
|
|
16
|
+
recipientLookupResolved,
|
|
16
17
|
updateE164PhoneNumberAddresses,
|
|
17
18
|
updateImportContactsProgress,
|
|
18
19
|
} from 'src/identity/actions'
|
|
@@ -212,6 +213,8 @@ export function* fetchAddressesAndValidateSaga({ e164Number }: FetchAddressesAnd
|
|
|
212
213
|
AppAnalytics.track(IdentityEvents.phone_number_lookup_error, {
|
|
213
214
|
error: error.message,
|
|
214
215
|
})
|
|
216
|
+
} finally {
|
|
217
|
+
yield* put(recipientLookupResolved())
|
|
215
218
|
}
|
|
216
219
|
}
|
|
217
220
|
|
|
@@ -239,6 +242,8 @@ export function* fetchAddressVerificationSaga({ address }: FetchAddressVerificat
|
|
|
239
242
|
)
|
|
240
243
|
yield* put(showErrorOrFallback(error, ErrorMessages.ADDRESS_LOOKUP_FAILURE))
|
|
241
244
|
AppAnalytics.track(IdentityEvents.address_lookup_error, { error: error.message })
|
|
245
|
+
} finally {
|
|
246
|
+
yield* put(recipientLookupResolved())
|
|
242
247
|
}
|
|
243
248
|
}
|
|
244
249
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Actions,
|
|
3
|
+
fetchAddressVerification,
|
|
4
|
+
fetchAddressesAndValidate,
|
|
5
|
+
recipientLookupResolved,
|
|
6
|
+
} from 'src/identity/actions'
|
|
7
|
+
import { reducer } from 'src/identity/reducer'
|
|
8
|
+
|
|
9
|
+
const initialState = reducer(undefined, { type: 'INIT' } as any)
|
|
10
|
+
|
|
11
|
+
describe('identity reducer', () => {
|
|
12
|
+
describe('recipientLookupLoading', () => {
|
|
13
|
+
it(`is set true on ${Actions.FETCH_ADDRESSES_AND_VALIDATION_STATUS}`, () => {
|
|
14
|
+
const next = reducer(initialState, fetchAddressesAndValidate('+15551234567'))
|
|
15
|
+
expect(next.recipientLookupLoading).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it(`is set true on ${Actions.FETCH_ADDRESS_VERIFICATION_STATUS}`, () => {
|
|
19
|
+
const next = reducer(initialState, fetchAddressVerification('0xabc'))
|
|
20
|
+
expect(next.recipientLookupLoading).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it(`is cleared on ${Actions.RECIPIENT_LOOKUP_RESOLVED}`, () => {
|
|
24
|
+
const loadingState = { ...initialState, recipientLookupLoading: true }
|
|
25
|
+
const next = reducer(loadingState, recipientLookupResolved())
|
|
26
|
+
expect(next.recipientLookupLoading).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
})
|
package/src/identity/reducer.ts
CHANGED
|
@@ -49,6 +49,8 @@ interface State {
|
|
|
49
49
|
importContactsProgress: ImportContactProgress
|
|
50
50
|
// Mapping of address to the entity that verified it (e.g. "valora", "minipay")
|
|
51
51
|
addressToVerifiedBy: AddressToVerifiedByType
|
|
52
|
+
// Single boolean is safe because both lookup sagas use `takeLatest` — at most one in flight.
|
|
53
|
+
recipientLookupLoading: boolean
|
|
52
54
|
lastSavedContactsHash: string | null
|
|
53
55
|
shouldRefreshStoredPasswordHash: boolean
|
|
54
56
|
}
|
|
@@ -64,6 +66,7 @@ const initialState: State = {
|
|
|
64
66
|
total: 0,
|
|
65
67
|
},
|
|
66
68
|
addressToVerifiedBy: {},
|
|
69
|
+
recipientLookupLoading: false,
|
|
67
70
|
lastSavedContactsHash: null,
|
|
68
71
|
shouldRefreshStoredPasswordHash: false,
|
|
69
72
|
}
|
|
@@ -85,6 +88,7 @@ export const reducer = (
|
|
|
85
88
|
current: 0,
|
|
86
89
|
total: 0,
|
|
87
90
|
},
|
|
91
|
+
recipientLookupLoading: false,
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
case Actions.UPDATE_E164_PHONE_NUMBER_ADDRESSES:
|
|
@@ -139,6 +143,11 @@ export const reducer = (
|
|
|
139
143
|
addressToE164Number: state.addressToE164Number,
|
|
140
144
|
e164NumberToAddress: state.e164NumberToAddress,
|
|
141
145
|
}
|
|
146
|
+
case Actions.FETCH_ADDRESSES_AND_VALIDATION_STATUS:
|
|
147
|
+
return {
|
|
148
|
+
...state,
|
|
149
|
+
recipientLookupLoading: true,
|
|
150
|
+
}
|
|
142
151
|
case Actions.FETCH_ADDRESS_VERIFICATION_STATUS:
|
|
143
152
|
return {
|
|
144
153
|
...state,
|
|
@@ -146,6 +155,12 @@ export const reducer = (
|
|
|
146
155
|
...state.addressToVerifiedBy,
|
|
147
156
|
[action.address]: undefined,
|
|
148
157
|
},
|
|
158
|
+
recipientLookupLoading: true,
|
|
159
|
+
}
|
|
160
|
+
case Actions.RECIPIENT_LOOKUP_RESOLVED:
|
|
161
|
+
return {
|
|
162
|
+
...state,
|
|
163
|
+
recipientLookupLoading: false,
|
|
149
164
|
}
|
|
150
165
|
case Actions.CONTACTS_SAVED:
|
|
151
166
|
return {
|
package/src/identity/saga.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { Actions } from 'src/identity/actions'
|
|
8
8
|
import Logger from 'src/utils/Logger'
|
|
9
9
|
import { safely } from 'src/utils/safely'
|
|
10
|
-
import { cancelled, spawn,
|
|
10
|
+
import { cancelled, spawn, takeLatest, takeLeading } from 'typed-redux-saga'
|
|
11
11
|
|
|
12
12
|
const TAG = 'identity/saga'
|
|
13
13
|
|
|
@@ -20,7 +20,7 @@ function* watchContactMapping() {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function* watchFetchAddressVerification() {
|
|
23
|
-
yield*
|
|
23
|
+
yield* takeLatest(Actions.FETCH_ADDRESS_VERIFICATION_STATUS, safely(fetchAddressVerificationSaga))
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function* identitySaga() {
|
|
@@ -3,6 +3,8 @@ import { RootState } from 'src/redux/reducers'
|
|
|
3
3
|
export const e164NumberToAddressSelector = (state: RootState) => state.identity.e164NumberToAddress
|
|
4
4
|
export const addressToE164NumberSelector = (state: RootState) => state.identity.addressToE164Number
|
|
5
5
|
export const addressToVerifiedBySelector = (state: RootState) => state.identity.addressToVerifiedBy
|
|
6
|
+
export const recipientLookupLoadingSelector = (state: RootState) =>
|
|
7
|
+
state.identity.recipientLookupLoading
|
|
6
8
|
export const importContactsProgressSelector = (state: RootState) =>
|
|
7
9
|
state.identity.importContactsProgress
|
|
8
10
|
export const addressToDisplayNameSelector = (state: RootState) =>
|
package/src/redux/migrations.ts
CHANGED
package/src/redux/store.test.ts
CHANGED
|
@@ -143,7 +143,7 @@ describe('store state', () => {
|
|
|
143
143
|
{
|
|
144
144
|
"_persist": {
|
|
145
145
|
"rehydrated": true,
|
|
146
|
-
"version":
|
|
146
|
+
"version": 258,
|
|
147
147
|
},
|
|
148
148
|
"account": {
|
|
149
149
|
"acceptedTerms": false,
|
|
@@ -253,6 +253,7 @@ describe('store state', () => {
|
|
|
253
253
|
"total": 0,
|
|
254
254
|
},
|
|
255
255
|
"lastSavedContactsHash": null,
|
|
256
|
+
"recipientLookupLoading": false,
|
|
256
257
|
"shouldRefreshStoredPasswordHash": true,
|
|
257
258
|
},
|
|
258
259
|
"imports": {
|
package/src/redux/store.ts
CHANGED
|
@@ -30,7 +30,7 @@ const persistConfig: PersistConfig<ReducersRootState> = {
|
|
|
30
30
|
key: 'root',
|
|
31
31
|
// default is -1, increment as we make migrations
|
|
32
32
|
// See https://github.com/valora-xyz/wallet/tree/main/WALLET.md#redux-state-migration
|
|
33
|
-
version:
|
|
33
|
+
version: 258,
|
|
34
34
|
keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems.
|
|
35
35
|
storage: FSStorage(),
|
|
36
36
|
blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup', transactionFeedV2Api.reducerPath],
|
package/src/send/EnterAmount.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import BackButton from 'src/components/BackButton'
|
|
|
10
10
|
import { BottomSheetModalRefType } from 'src/components/BottomSheet'
|
|
11
11
|
import Button, { BtnSizes } from 'src/components/Button'
|
|
12
12
|
import FeeInfoBottomSheet from 'src/components/FeeInfoBottomSheet'
|
|
13
|
-
import { FilterChip } from 'src/components/FilterChipsCarousel'
|
|
13
|
+
import { FilterChip, isNetworkChip } from 'src/components/FilterChipsCarousel'
|
|
14
14
|
import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification'
|
|
15
15
|
import KeyboardAwareScrollView from 'src/components/KeyboardAwareScrollView'
|
|
16
16
|
import { ReviewDetailsItem } from 'src/components/ReviewTransaction'
|
|
@@ -34,6 +34,7 @@ import { Spacing } from 'src/styles/styles'
|
|
|
34
34
|
import { feeCurrenciesSelector } from 'src/tokens/selectors'
|
|
35
35
|
import { TokenBalance } from 'src/tokens/slice'
|
|
36
36
|
import { PreparedTransactionsResult } from 'src/viem/prepareTransactions'
|
|
37
|
+
import networkConfig from 'src/web3/networkConfig'
|
|
37
38
|
|
|
38
39
|
export interface ProceedArgs {
|
|
39
40
|
tokenAmount: BigNumber
|
|
@@ -113,9 +114,20 @@ export default function EnterAmount({
|
|
|
113
114
|
}: Props) {
|
|
114
115
|
const { t } = useTranslation()
|
|
115
116
|
const insets = useSafeAreaInsets()
|
|
116
|
-
const [token, setToken] = useState<TokenBalance>(() =>
|
|
117
|
+
const [token, setToken] = useState<TokenBalance | undefined>(() => {
|
|
118
|
+
if (defaultToken) return defaultToken
|
|
119
|
+
const activeFilters = filterChips?.filter((chip) => chip.isSelected) ?? []
|
|
120
|
+
const selectableTokens = tokens.filter((t) =>
|
|
121
|
+
activeFilters.every((filter) =>
|
|
122
|
+
isNetworkChip(filter) ? filter.filterFn(t, filter.selectedNetworkIds) : filter.filterFn(t)
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return selectableTokens[0]
|
|
126
|
+
})
|
|
117
127
|
const [selectedPercentage, setSelectedPercentage] = useState<number | null>(null)
|
|
118
|
-
const feeCurrencies = useSelector((state) =>
|
|
128
|
+
const feeCurrencies = useSelector((state) =>
|
|
129
|
+
feeCurrenciesSelector(state, token?.networkId ?? networkConfig.defaultNetworkId)
|
|
130
|
+
)
|
|
119
131
|
const networkFee = useNetworkFee(prepareTransactionsResult)
|
|
120
132
|
const localCurrencySymbol = useSelector(getLocalCurrencySymbol) ?? LocalCurrencySymbol.USD
|
|
121
133
|
|
|
@@ -142,6 +154,8 @@ export default function EnterAmount({
|
|
|
142
154
|
useEffect(() => {
|
|
143
155
|
onClearPreparedTransactions()
|
|
144
156
|
|
|
157
|
+
if (!token) return
|
|
158
|
+
|
|
145
159
|
const canRefresh =
|
|
146
160
|
processedAmounts.token.bignum &&
|
|
147
161
|
processedAmounts.token.bignum.gt(0) &&
|
|
@@ -158,9 +172,9 @@ export default function EnterAmount({
|
|
|
158
172
|
const onOpenTokenPicker = () => {
|
|
159
173
|
tokenBottomSheetRef.current?.snapToIndex(0)
|
|
160
174
|
AppAnalytics.track(SendEvents.token_dropdown_opened, {
|
|
161
|
-
currentTokenId: token
|
|
162
|
-
currentTokenAddress: token
|
|
163
|
-
currentNetworkId: token
|
|
175
|
+
currentTokenId: token?.tokenId ?? '',
|
|
176
|
+
currentTokenAddress: token?.address ?? null,
|
|
177
|
+
currentNetworkId: token?.networkId ?? null,
|
|
164
178
|
})
|
|
165
179
|
}
|
|
166
180
|
|
|
@@ -174,6 +188,7 @@ export default function EnterAmount({
|
|
|
174
188
|
}
|
|
175
189
|
|
|
176
190
|
const onSelectPercentageAmount = (percentage: number) => {
|
|
191
|
+
if (!token) return
|
|
177
192
|
handleSelectPercentageAmount(percentage)
|
|
178
193
|
setSelectedPercentage(percentage)
|
|
179
194
|
|
|
@@ -187,6 +202,7 @@ export default function EnterAmount({
|
|
|
187
202
|
}
|
|
188
203
|
|
|
189
204
|
const showLowerAmountError =
|
|
205
|
+
token &&
|
|
190
206
|
processedAmounts.token.bignum &&
|
|
191
207
|
!processedAmounts.token.bignum.lte(token.balance) &&
|
|
192
208
|
!disableBalanceCheck
|
|
@@ -205,6 +221,7 @@ export default function EnterAmount({
|
|
|
205
221
|
prepareTransactionsResult.transactions.length > 0
|
|
206
222
|
|
|
207
223
|
const disabled =
|
|
224
|
+
!token ||
|
|
208
225
|
disableProceed ||
|
|
209
226
|
(disableBalanceCheck ? !!processedAmounts.token.bignum?.isZero() : !transactionIsPossible)
|
|
210
227
|
|
|
@@ -236,89 +253,95 @@ export default function EnterAmount({
|
|
|
236
253
|
onOpenTokenPicker={tokenSelectionDisabled ? undefined : onOpenTokenPicker}
|
|
237
254
|
/>
|
|
238
255
|
|
|
239
|
-
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
256
|
+
{token &&
|
|
257
|
+
prepareTransactionsResult?.type !== 'not-enough-balance-for-gas' &&
|
|
258
|
+
!!networkFee && (
|
|
259
|
+
<View style={styles.feeContainer}>
|
|
260
|
+
<ReviewDetailsItem
|
|
261
|
+
approx
|
|
262
|
+
testID="SendEnterAmount/NetworkFee"
|
|
263
|
+
type="token-amount"
|
|
264
|
+
label={t('networkFee')}
|
|
265
|
+
tokenAmount={networkFee.amount}
|
|
266
|
+
localAmount={networkFee.localAmount}
|
|
267
|
+
tokenInfo={networkFee.token}
|
|
268
|
+
localCurrencySymbol={localCurrencySymbol}
|
|
269
|
+
onInfoPress={() => feeInfoBottomSheetRef.current?.snapToIndex(0)}
|
|
270
|
+
/>
|
|
252
271
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
272
|
+
<FeeInfoBottomSheet forwardedRef={feeInfoBottomSheetRef} networkFee={networkFee} />
|
|
273
|
+
</View>
|
|
274
|
+
)}
|
|
256
275
|
</View>
|
|
257
276
|
|
|
258
|
-
{
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
277
|
+
{token && (
|
|
278
|
+
<>
|
|
279
|
+
{showLowerAmountError && (
|
|
280
|
+
<InLineNotification
|
|
281
|
+
variant={NotificationVariant.Warning}
|
|
282
|
+
title={t('sendEnterAmountScreen.insufficientBalanceWarning.title', {
|
|
283
|
+
tokenSymbol: token.symbol,
|
|
284
|
+
})}
|
|
285
|
+
description={t('sendEnterAmountScreen.insufficientBalanceWarning.description', {
|
|
286
|
+
tokenSymbol: token.symbol,
|
|
287
|
+
})}
|
|
288
|
+
style={styles.warning}
|
|
289
|
+
testID="SendEnterAmount/NotEnoughBalanceWarning"
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
{showMaxAmountWarning && (
|
|
293
|
+
<InLineNotification
|
|
294
|
+
variant={NotificationVariant.Warning}
|
|
295
|
+
title={t('sendEnterAmountScreen.maxAmountWarning.title')}
|
|
296
|
+
description={t('sendEnterAmountScreen.maxAmountWarning.description', {
|
|
297
|
+
feeTokenSymbol: prepareTransactionsResult.feeCurrency.symbol,
|
|
298
|
+
})}
|
|
299
|
+
style={styles.warning}
|
|
300
|
+
testID="SendEnterAmount/MaxAmountWarning"
|
|
301
|
+
/>
|
|
302
|
+
)}
|
|
303
|
+
{showNotEnoughBalanceForGasWarning && (
|
|
304
|
+
<InLineNotification
|
|
305
|
+
variant={NotificationVariant.Warning}
|
|
306
|
+
title={t('sendEnterAmountScreen.notEnoughBalanceForGasWarning.title', {
|
|
307
|
+
feeTokenSymbol: prepareTransactionsResult.feeCurrencies[0].symbol,
|
|
308
|
+
})}
|
|
309
|
+
description={t('sendEnterAmountScreen.notEnoughBalanceForGasWarning.description', {
|
|
310
|
+
feeTokenSymbol: prepareTransactionsResult.feeCurrencies[0].symbol,
|
|
311
|
+
})}
|
|
312
|
+
style={styles.warning}
|
|
313
|
+
testID="SendEnterAmount/NotEnoughForGasWarning"
|
|
314
|
+
/>
|
|
315
|
+
)}
|
|
316
|
+
{prepareTransactionError && (
|
|
317
|
+
<InLineNotification
|
|
318
|
+
variant={NotificationVariant.Error}
|
|
319
|
+
title={t('sendEnterAmountScreen.prepareTransactionError.title')}
|
|
320
|
+
description={t('sendEnterAmountScreen.prepareTransactionError.description')}
|
|
321
|
+
style={styles.warning}
|
|
322
|
+
testID="SendEnterAmount/PrepareTransactionError"
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
304
325
|
|
|
305
|
-
|
|
326
|
+
{children}
|
|
306
327
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
328
|
+
<EnterAmountOptions
|
|
329
|
+
onPressAmount={onSelectPercentageAmount}
|
|
330
|
+
selectedAmount={selectedPercentage}
|
|
331
|
+
testID="SendEnterAmount/AmountOptions"
|
|
332
|
+
/>
|
|
312
333
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
334
|
+
<ProceedComponent
|
|
335
|
+
tokenAmount={processedAmounts.token.bignum}
|
|
336
|
+
localAmount={processedAmounts.local.bignum}
|
|
337
|
+
token={token}
|
|
338
|
+
amountEnteredIn={amountType}
|
|
339
|
+
onPressProceed={onPressProceed}
|
|
340
|
+
disabled={disabled}
|
|
341
|
+
showLoading={prepareTransactionsLoading}
|
|
342
|
+
/>
|
|
343
|
+
</>
|
|
344
|
+
)}
|
|
322
345
|
</KeyboardAwareScrollView>
|
|
323
346
|
<TokenBottomSheet
|
|
324
347
|
forwardedRef={tokenBottomSheetRef}
|
|
@@ -308,6 +308,33 @@ describe('SendEnterAmount', () => {
|
|
|
308
308
|
expect(queryByText('sendEnterAmountScreen.miniPayFilterChip')).toBeFalsy()
|
|
309
309
|
})
|
|
310
310
|
|
|
311
|
+
it('should not select a default token when isMiniPayRecipient is true and user has no MiniPay tokens', () => {
|
|
312
|
+
jest.mocked(getDynamicConfigParams).mockReturnValue({
|
|
313
|
+
miniPayTokenIds: ['celo-alfajores:0xNOT_HELD_BY_USER'],
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const { getByText, queryByTestId, getByTestId } = render(
|
|
317
|
+
<Provider store={store}>
|
|
318
|
+
<MockedNavigator
|
|
319
|
+
component={SendEnterAmount}
|
|
320
|
+
params={{ ...params, isMiniPayRecipient: true }}
|
|
321
|
+
/>
|
|
322
|
+
</Provider>
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
// "Select token" placeholder is rendered in place of the selected token
|
|
326
|
+
expect(getByText('tokenEnterAmount.selectToken')).toBeTruthy()
|
|
327
|
+
// Amount input, percentage options, and Review button are not rendered
|
|
328
|
+
expect(queryByTestId('SendEnterAmount/TokenAmountInput')).toBeNull()
|
|
329
|
+
expect(queryByTestId('SendEnterAmount/AmountOptions')).toBeNull()
|
|
330
|
+
expect(queryByTestId('SendEnterAmount/ReviewButton')).toBeNull()
|
|
331
|
+
// MiniPay filter chip is still present (inside the token picker)
|
|
332
|
+
expect(getByText('sendEnterAmountScreen.miniPayFilterChip')).toBeTruthy()
|
|
333
|
+
// Tapping the token row opens the picker
|
|
334
|
+
fireEvent.press(getByTestId('SendEnterAmount/TokenSelect'))
|
|
335
|
+
expect(getByTestId('TokenBottomSheet')).toBeTruthy()
|
|
336
|
+
})
|
|
337
|
+
|
|
311
338
|
it('should include isMiniPayRecipient in send_amount_continue analytics', async () => {
|
|
312
339
|
jest.mocked(usePrepareSendTransactions).mockReturnValue({
|
|
313
340
|
prepareTransactionsResult: mockPrepareTransactionsResultPossible,
|
|
@@ -6,14 +6,19 @@ import AppAnalytics from 'src/analytics/AppAnalytics'
|
|
|
6
6
|
import { SendEvents } from 'src/analytics/Events'
|
|
7
7
|
import { SendOrigin } from 'src/analytics/types'
|
|
8
8
|
import { getAppConfig } from 'src/appConfig'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
fetchAddressVerification,
|
|
11
|
+
fetchAddressesAndValidate,
|
|
12
|
+
recipientLookupResolved,
|
|
13
|
+
} from 'src/identity/actions'
|
|
10
14
|
import { navigate } from 'src/navigator/NavigationService'
|
|
11
15
|
import { Screens } from 'src/navigator/Screens'
|
|
12
16
|
import { RecipientType } from 'src/recipients/recipient'
|
|
13
17
|
import SendSelectRecipient from 'src/send/SendSelectRecipient'
|
|
14
18
|
import { getDynamicConfigParams } from 'src/statsig'
|
|
15
19
|
import { StatsigDynamicConfigs } from 'src/statsig/types'
|
|
16
|
-
import {
|
|
20
|
+
import { setupStore } from 'src/redux/store'
|
|
21
|
+
import { createMockStore, getMockStackScreenProps, getMockStoreData } from 'test/utils'
|
|
17
22
|
import {
|
|
18
23
|
mockAccount,
|
|
19
24
|
mockAccount2,
|
|
@@ -33,6 +38,10 @@ jest.mock('src/recipients/resolve-id')
|
|
|
33
38
|
|
|
34
39
|
jest.mock('react-native-device-info', () => ({ getFontScaleSync: () => 1 }))
|
|
35
40
|
jest.mock('src/statsig')
|
|
41
|
+
jest.mock('src/redux/sagas', () => ({
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
43
|
+
rootSaga: jest.fn(function* () {}),
|
|
44
|
+
}))
|
|
36
45
|
|
|
37
46
|
const mockScreenProps = ({
|
|
38
47
|
defaultTokenIdOverride,
|
|
@@ -197,6 +206,37 @@ describe('SendSelectRecipient', () => {
|
|
|
197
206
|
})
|
|
198
207
|
expect(getByTestId('SelectRecipient/NoResults')).toBeTruthy()
|
|
199
208
|
})
|
|
209
|
+
describe('selection spinner', () => {
|
|
210
|
+
it('shows the spinner while the lookup is in flight and hides it once the saga resolves (error path)', async () => {
|
|
211
|
+
// Reducer-backed store: tapping a recipient dispatches `fetchAddressesAndValidate`
|
|
212
|
+
// which flips `recipientLookupLoading` to true; dispatching `recipientLookupResolved`
|
|
213
|
+
// (the saga's `finally` branch) flips it back to false. We assert the row spinner
|
|
214
|
+
// tracks the actual state transition, including the no-mapping (error) end state.
|
|
215
|
+
const { store } = setupStore(getMockStoreData(storeWithPhoneVerified))
|
|
216
|
+
|
|
217
|
+
const { getByTestId, queryByTestId } = render(
|
|
218
|
+
<Provider store={store}>
|
|
219
|
+
<SendSelectRecipient {...mockScreenProps({})} />
|
|
220
|
+
</Provider>
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
await act(() => {
|
|
224
|
+
fireEvent.changeText(getByTestId('SendSelectRecipientSearchInput'), 'George Bogart')
|
|
225
|
+
})
|
|
226
|
+
await act(() => {
|
|
227
|
+
fireEvent.press(getByTestId('RecipientItem'))
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
expect(queryByTestId('RecipientItem/ActivityIndicator')).toBeTruthy()
|
|
231
|
+
|
|
232
|
+
await act(() => {
|
|
233
|
+
store.dispatch(recipientLookupResolved())
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
expect(queryByTestId('RecipientItem/ActivityIndicator')).toBeFalsy()
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
200
240
|
it('navigates to send amount when a verified phone recipient is tapped in search results', async () => {
|
|
201
241
|
const store = createMockStore({
|
|
202
242
|
...storeWithPhoneVerified,
|
|
@@ -184,8 +184,13 @@ function SendSelectRecipient({ route }: Props) {
|
|
|
184
184
|
const { contactRecipients, recentRecipients } = useSendRecipients()
|
|
185
185
|
const { mergedRecipients, searchQuery, setSearchQuery } = useMergedSearchRecipients(onSearch)
|
|
186
186
|
|
|
187
|
-
const {
|
|
188
|
-
|
|
187
|
+
const {
|
|
188
|
+
recipientVerificationStatus,
|
|
189
|
+
recipient,
|
|
190
|
+
setSelectedRecipient,
|
|
191
|
+
unsetSelectedRecipient,
|
|
192
|
+
isSelectedRecipientLoading,
|
|
193
|
+
} = useFetchRecipientVerificationStatus()
|
|
189
194
|
|
|
190
195
|
useEffect(() => {
|
|
191
196
|
// Auto-navigate once verification resolves. The picker stays mounted so the
|
|
@@ -280,9 +285,7 @@ function SendSelectRecipient({ route }: Props) {
|
|
|
280
285
|
recipients={mergedRecipients}
|
|
281
286
|
onSelectRecipient={setSelectedRecipient}
|
|
282
287
|
selectedRecipient={recipient}
|
|
283
|
-
isSelectedRecipientLoading={
|
|
284
|
-
!!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
|
|
285
|
-
}
|
|
288
|
+
isSelectedRecipientLoading={isSelectedRecipientLoading}
|
|
286
289
|
/>
|
|
287
290
|
</>
|
|
288
291
|
)
|
|
@@ -327,9 +330,7 @@ function SendSelectRecipient({ route }: Props) {
|
|
|
327
330
|
recipients={contactRecipients}
|
|
328
331
|
onSelectRecipient={setSelectedRecipient}
|
|
329
332
|
selectedRecipient={recipient}
|
|
330
|
-
isSelectedRecipientLoading={
|
|
331
|
-
!!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
|
|
332
|
-
}
|
|
333
|
+
isSelectedRecipientLoading={isSelectedRecipientLoading}
|
|
333
334
|
/>
|
|
334
335
|
) : (
|
|
335
336
|
<>
|
|
@@ -344,9 +345,7 @@ function SendSelectRecipient({ route }: Props) {
|
|
|
344
345
|
title={t('sendSelectRecipient.recents')}
|
|
345
346
|
onSelectRecipient={onSelectRecentRecipient}
|
|
346
347
|
selectedRecipient={recipient}
|
|
347
|
-
isSelectedRecipientLoading={
|
|
348
|
-
!!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
|
|
349
|
-
}
|
|
348
|
+
isSelectedRecipientLoading={isSelectedRecipientLoading}
|
|
350
349
|
style={styles.recentRecipientPicker}
|
|
351
350
|
/>
|
|
352
351
|
) : (
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react-native'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { Provider } from 'react-redux'
|
|
4
|
+
import {
|
|
5
|
+
fetchAddressVerification,
|
|
6
|
+
fetchAddressesAndValidate,
|
|
7
|
+
recipientLookupResolved,
|
|
8
|
+
} from 'src/identity/actions'
|
|
9
|
+
import { RecipientVerificationStatus } from 'src/identity/types'
|
|
10
|
+
import { Recipient, RecipientType } from 'src/recipients/recipient'
|
|
11
|
+
import { setupStore } from 'src/redux/store'
|
|
12
|
+
import useFetchRecipientVerificationStatus from 'src/send/useFetchRecipientVerificationStatus'
|
|
13
|
+
import { getMockStoreData } from 'test/utils'
|
|
14
|
+
import { mockAccount, mockE164Number, mockName } from 'test/values'
|
|
15
|
+
|
|
16
|
+
jest.mock('src/redux/sagas', () => ({
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
18
|
+
rootSaga: jest.fn(function* () {}),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const phoneRecipient: Recipient = {
|
|
22
|
+
name: mockName,
|
|
23
|
+
contactId: 'contactId',
|
|
24
|
+
e164PhoneNumber: mockE164Number,
|
|
25
|
+
recipientType: RecipientType.PhoneNumber,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const addressRecipient: Recipient = {
|
|
29
|
+
address: mockAccount,
|
|
30
|
+
recipientType: RecipientType.Address,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setupHook(storeOverrides: Parameters<typeof getMockStoreData>[0] = {}) {
|
|
34
|
+
const { store } = setupStore(getMockStoreData(storeOverrides))
|
|
35
|
+
const dispatchSpy = jest.spyOn(store, 'dispatch')
|
|
36
|
+
const { result, rerender } = renderHook(() => useFetchRecipientVerificationStatus(), {
|
|
37
|
+
wrapper: ({ children }: { children: React.ReactNode }) => (
|
|
38
|
+
<Provider store={store}>{children}</Provider>
|
|
39
|
+
),
|
|
40
|
+
})
|
|
41
|
+
return { result, rerender, store, dispatchSpy }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('useFetchRecipientVerificationStatus', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
jest.clearAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('starts with no recipient and not loading', () => {
|
|
50
|
+
const { result } = setupHook()
|
|
51
|
+
expect(result.current.recipient).toBeNull()
|
|
52
|
+
expect(result.current.recipientVerificationStatus).toBe(RecipientVerificationStatus.UNKNOWN)
|
|
53
|
+
expect(result.current.isSelectedRecipientLoading).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('phone-number recipient', () => {
|
|
57
|
+
it('dispatches fetchAddressesAndValidate when selected', () => {
|
|
58
|
+
const { result, dispatchSpy } = setupHook()
|
|
59
|
+
act(() => {
|
|
60
|
+
result.current.setSelectedRecipient(phoneRecipient)
|
|
61
|
+
})
|
|
62
|
+
expect(dispatchSpy).toHaveBeenCalledWith(fetchAddressesAndValidate(mockE164Number))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('reports loading once the lookup is dispatched and stops once it resolves', () => {
|
|
66
|
+
const { result, store } = setupHook()
|
|
67
|
+
|
|
68
|
+
act(() => {
|
|
69
|
+
result.current.setSelectedRecipient(phoneRecipient)
|
|
70
|
+
})
|
|
71
|
+
expect(result.current.isSelectedRecipientLoading).toBe(true)
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
store.dispatch(recipientLookupResolved())
|
|
75
|
+
})
|
|
76
|
+
expect(result.current.isSelectedRecipientLoading).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('address recipient', () => {
|
|
81
|
+
it('dispatches fetchAddressVerification when phone is verified', () => {
|
|
82
|
+
const { result, dispatchSpy } = setupHook({
|
|
83
|
+
app: { phoneNumberVerified: true },
|
|
84
|
+
})
|
|
85
|
+
act(() => {
|
|
86
|
+
result.current.setSelectedRecipient(addressRecipient)
|
|
87
|
+
})
|
|
88
|
+
expect(dispatchSpy).toHaveBeenCalledWith(fetchAddressVerification(mockAccount))
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('marks address recipient as unverified when phone is not verified', () => {
|
|
92
|
+
const { result } = setupHook({ app: { phoneNumberVerified: false } })
|
|
93
|
+
act(() => {
|
|
94
|
+
result.current.setSelectedRecipient(addressRecipient)
|
|
95
|
+
})
|
|
96
|
+
expect(result.current.recipientVerificationStatus).toBe(
|
|
97
|
+
RecipientVerificationStatus.UNVERIFIED
|
|
98
|
+
)
|
|
99
|
+
expect(result.current.isSelectedRecipientLoading).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('reports loading once the lookup is dispatched and stops once it resolves', () => {
|
|
103
|
+
const { result, store } = setupHook({ app: { phoneNumberVerified: true } })
|
|
104
|
+
|
|
105
|
+
act(() => {
|
|
106
|
+
result.current.setSelectedRecipient(addressRecipient)
|
|
107
|
+
})
|
|
108
|
+
expect(result.current.isSelectedRecipientLoading).toBe(true)
|
|
109
|
+
|
|
110
|
+
act(() => {
|
|
111
|
+
store.dispatch(recipientLookupResolved())
|
|
112
|
+
})
|
|
113
|
+
expect(result.current.isSelectedRecipientLoading).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('clears recipient and verification status on unset', () => {
|
|
118
|
+
const { result } = setupHook()
|
|
119
|
+
act(() => {
|
|
120
|
+
result.current.setSelectedRecipient(phoneRecipient)
|
|
121
|
+
})
|
|
122
|
+
expect(result.current.isSelectedRecipientLoading).toBe(true)
|
|
123
|
+
|
|
124
|
+
act(() => {
|
|
125
|
+
result.current.unsetSelectedRecipient()
|
|
126
|
+
})
|
|
127
|
+
expect(result.current.recipient).toBeNull()
|
|
128
|
+
expect(result.current.recipientVerificationStatus).toBe(RecipientVerificationStatus.UNKNOWN)
|
|
129
|
+
expect(result.current.isSelectedRecipientLoading).toBe(false)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { phoneNumberVerifiedSelector } from 'src/app/selectors'
|
|
3
3
|
import { fetchAddressVerification, fetchAddressesAndValidate } from 'src/identity/actions'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
addressToVerifiedBySelector,
|
|
6
|
+
e164NumberToAddressSelector,
|
|
7
|
+
recipientLookupLoadingSelector,
|
|
8
|
+
} from 'src/identity/selectors'
|
|
5
9
|
import { RecipientVerificationStatus } from 'src/identity/types'
|
|
6
10
|
import { Recipient, RecipientType, getRecipientVerificationStatus } from 'src/recipients/recipient'
|
|
7
11
|
import { useDispatch, useSelector } from 'src/redux/hooks'
|
|
@@ -14,6 +18,7 @@ const useFetchRecipientVerificationStatus = () => {
|
|
|
14
18
|
|
|
15
19
|
const e164NumberToAddress = useSelector(e164NumberToAddressSelector)
|
|
16
20
|
const addressToVerifiedBy = useSelector(addressToVerifiedBySelector)
|
|
21
|
+
const recipientLookupLoading = useSelector(recipientLookupLoadingSelector)
|
|
17
22
|
const phoneNumberVerified = useSelector(phoneNumberVerifiedSelector)
|
|
18
23
|
const dispatch = useDispatch()
|
|
19
24
|
|
|
@@ -51,11 +56,14 @@ const useFetchRecipientVerificationStatus = () => {
|
|
|
51
56
|
}
|
|
52
57
|
}, [e164NumberToAddress, addressToVerifiedBy, recipient, recipientVerificationStatus])
|
|
53
58
|
|
|
59
|
+
const isSelectedRecipientLoading = !!recipient && recipientLookupLoading
|
|
60
|
+
|
|
54
61
|
return {
|
|
55
62
|
recipient,
|
|
56
63
|
setSelectedRecipient,
|
|
57
64
|
unsetSelectedRecipient,
|
|
58
65
|
recipientVerificationStatus,
|
|
66
|
+
isSelectedRecipientLoading,
|
|
59
67
|
}
|
|
60
68
|
}
|
|
61
69
|
|