wallet-stack 1.0.0-alpha.130 → 1.0.0-alpha.132

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.
@@ -2229,6 +2229,12 @@
2229
2229
  "invite": "Invite"
2230
2230
  }
2231
2231
  },
2232
+ "sendInvite": {
2233
+ "title": "Invite {{contact}} to {{appName}}",
2234
+ "body": "Send them a link to {{appName}} so they can get a wallet and start receiving crypto.",
2235
+ "cta": "Share invite",
2236
+ "errorRetry": "Couldn't open the share sheet. Please try again."
2237
+ },
2232
2238
  "transactionStatus": {
2233
2239
  "transactionIsCompleted": "Completed",
2234
2240
  "transactionIsPending": "Pending",
@@ -2269,7 +2275,8 @@
2269
2275
  "insufficientBalanceWarning": {
2270
2276
  "title": "You need more {{tokenSymbol}}",
2271
2277
  "description": "Insufficient {{tokenSymbol}} balance. Try a smaller amount or choose a different asset."
2272
- }
2278
+ },
2279
+ "miniPayFilterChip": "MiniPay supported"
2273
2280
  },
2274
2281
  "jumpstartIntro": {
2275
2282
  "title": "Share crypto like a text",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallet-stack",
3
- "version": "1.0.0-alpha.130",
3
+ "version": "1.0.0-alpha.132",
4
4
  "author": "Valora Inc",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -18,7 +18,7 @@
18
18
  "provenance": true
19
19
  },
20
20
  "engines": {
21
- "node": ">=20.20.2"
21
+ "node": ">=24.15.0"
22
22
  },
23
23
  "files": [
24
24
  "tsconfig.json",
@@ -37,4 +37,5 @@ export enum ErrorMessages {
37
37
  KYC_TRY_AGAIN_FAILED = 'fiatConnectKycStatusScreen.tryAgainFailed',
38
38
  HOOKS_INVALID_PREVIEW_API_URL = 'hooksPreview.invalidApiUrl',
39
39
  SHORTCUT_CLAIM_REWARD_FAILED = 'dappShortcuts.claimRewardFailure',
40
+ SHARE_INVITE_FAILED = 'sendInvite.errorRetry',
40
41
  }
@@ -14,6 +14,7 @@ export const celoEducation4 = require('src/images/assets/celo-education-4.png')
14
14
  export const email = require('src/images/assets/email.png')
15
15
  export const fiatExchange = require('src/images/assets/fiat-exchange.png')
16
16
  export const getVerified = require('src/images/assets/get-verified.png')
17
+ export const inviteModal = require('src/images/assets/invite-modal.png')
17
18
  export const learnCelo = require('src/images/assets/learn-celo.png')
18
19
  export const pointsCardBackground = require('src/images/assets/points-card-background.png')
19
20
  export const pointsIllustration = require('src/images/assets/points-illustration.png')
@@ -104,6 +104,7 @@ import { RootState } from 'src/redux/reducers'
104
104
  import { store } from 'src/redux/store'
105
105
  import SendConfirmation, { sendConfirmationScreenNavOptions } from 'src/send/SendConfirmation'
106
106
  import SendEnterAmount from 'src/send/SendEnterAmount'
107
+ import SendInvite from 'src/send/SendInvite'
107
108
  import SendSelectRecipient from 'src/send/SendSelectRecipient'
108
109
  import ValidateRecipientAccount, {
109
110
  validateRecipientAccountScreenNavOptions,
@@ -256,6 +257,11 @@ const sendScreens = (Navigator: typeof Stack) => (
256
257
  component={SendEnterAmount}
257
258
  options={noHeader}
258
259
  />
260
+ <Navigator.Screen
261
+ name={Screens.SendInvite}
262
+ component={SendInvite}
263
+ options={SendInvite.navigationOptions}
264
+ />
259
265
  </>
260
266
  )
261
267
 
@@ -75,6 +75,7 @@ export enum Screens {
75
75
  SelectCountry = 'SelectCountry',
76
76
  SelectLocalCurrency = 'SelectLocalCurrency',
77
77
  SelectProvider = 'SelectProvider',
78
+ SendInvite = 'SendInvite',
78
79
  SendSelectRecipient = 'SendSelectRecipient',
79
80
  SendConfirmation = 'SendConfirmation',
80
81
  SendEnterAmount = 'SendEnterAmount',
@@ -261,6 +261,7 @@ export type StackParamList = {
261
261
  fiat: number
262
262
  }
263
263
  }
264
+ [Screens.SendInvite]: { recipient: Recipient; shareUrl: string }
264
265
  [Screens.SendSelectRecipient]:
265
266
  | {
266
267
  forceTokenId?: boolean
@@ -171,6 +171,34 @@ describe('SendConfirmation', () => {
171
171
  expect(getByTestId('ConfirmButton')).toHaveTextContent('send', { exact: false })
172
172
  })
173
173
 
174
+ it('shows the unknown address warning when the recipient address is not a known app user', () => {
175
+ const { getByTestId } = renderScreen(mockSendConfirmationProps, {
176
+ identity: {
177
+ addressToVerificationStatus: { [mockAccount]: false },
178
+ },
179
+ })
180
+
181
+ expect(getByTestId('UnknownAddressInfo')).toBeTruthy()
182
+ })
183
+
184
+ it('does not show the unknown address warning when the recipient address is verified', () => {
185
+ const { queryByTestId } = renderScreen(mockSendConfirmationProps, {
186
+ identity: {
187
+ addressToVerificationStatus: { [mockAccount]: true },
188
+ },
189
+ })
190
+
191
+ expect(queryByTestId('UnknownAddressInfo')).toBeNull()
192
+ })
193
+
194
+ it('does not show the unknown address warning when the address has not been looked up yet', () => {
195
+ const { queryByTestId } = renderScreen(mockSendConfirmationProps, {
196
+ identity: { addressToVerificationStatus: {} },
197
+ })
198
+
199
+ expect(queryByTestId('UnknownAddressInfo')).toBeNull()
200
+ })
201
+
174
202
  it('does not prepare a transaction on load by default', () => {
175
203
  renderScreen(mockSendConfirmationProps)
176
204
  expect(mockUsePrepareSendTransactionsOutput.clearPreparedTransactions).not.toHaveBeenCalled()
@@ -10,6 +10,7 @@ import BackButton from 'src/components/BackButton'
10
10
  import type { BottomSheetModalRefType } from 'src/components/BottomSheet'
11
11
  import Button, { BtnSizes } from 'src/components/Button'
12
12
  import InfoBottomSheet, { InfoBottomSheetContentBlock } from 'src/components/InfoBottomSheet'
13
+ import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification'
13
14
  import {
14
15
  buildAmounts,
15
16
  ReviewContent,
@@ -23,11 +24,13 @@ import {
23
24
  } from 'src/components/ReviewTransaction'
24
25
  import { formatValueToDisplay } from 'src/components/TokenDisplay'
25
26
  import TokenIcon from 'src/components/TokenIcon'
27
+ import { addressToVerificationStatusSelector } from 'src/identity/selectors'
26
28
  import { LocalCurrencySymbol } from 'src/localCurrency/consts'
27
29
  import { getLocalCurrencyCode, getLocalCurrencySymbol } from 'src/localCurrency/selectors'
28
30
  import { noHeader } from 'src/navigator/Headers'
29
31
  import { Screens } from 'src/navigator/Screens'
30
32
  import { StackParamList } from 'src/navigator/types'
33
+ import { RecipientType } from 'src/recipients/recipient'
31
34
  import { useDispatch, useSelector } from 'src/redux/hooks'
32
35
  import { sendPayment } from 'src/send/actions'
33
36
  import { isSendingSelector } from 'src/send/selectors'
@@ -36,11 +39,11 @@ import { NETWORK_NAMES } from 'src/shared/conts'
36
39
  import { useAmountAsUsd, useTokenInfo, useTokenToLocalAmount } from 'src/tokens/hooks'
37
40
  import { feeCurrenciesSelector } from 'src/tokens/selectors'
38
41
  import Logger from 'src/utils/Logger'
39
- import { getFeeCurrencyAndAmounts } from 'src/viem/prepareTransactions'
40
42
  import {
41
43
  getPreparedTransactionsPossible,
42
44
  getSerializablePreparedTransaction,
43
45
  } from 'src/viem/preparedTransactionSerialization'
46
+ import { getFeeCurrencyAndAmounts } from 'src/viem/prepareTransactions'
44
47
  import { walletAddressSelector } from 'src/web3/selectors'
45
48
 
46
49
  type Props = NativeStackScreenProps<StackParamList, Screens.SendConfirmation>
@@ -88,6 +91,12 @@ export default function SendConfirmation({ route: { params } }: Props) {
88
91
  const localAmount = useTokenToLocalAmount(tokenAmount, tokenId)
89
92
  const usdAmount = useAmountAsUsd(tokenAmount, tokenId)
90
93
  const walletAddress = useSelector(walletAddressSelector)
94
+ const addressToVerificationStatus = useSelector(addressToVerificationStatusSelector)
95
+ const showUnknownAddressInfo =
96
+ recipient.recipientType === RecipientType.Address &&
97
+ !!recipient.address &&
98
+ addressToVerificationStatus[recipient.address] === false
99
+
91
100
  const feeCurrencies = useSelector((state) => feeCurrenciesSelector(state, tokenInfo!.networkId))
92
101
  const {
93
102
  maxFeeAmount,
@@ -233,6 +242,14 @@ export default function SendConfirmation({ route: { params } }: Props) {
233
242
  </ReviewContent>
234
243
 
235
244
  <ReviewFooter>
245
+ {showUnknownAddressInfo && (
246
+ <InLineNotification
247
+ variant={NotificationVariant.Info}
248
+ description={t('sendSelectRecipient.unknownAddressInfo')}
249
+ testID="UnknownAddressInfo"
250
+ />
251
+ )}
252
+
236
253
  <Button
237
254
  testID="ConfirmButton"
238
255
  text={t('send')}
@@ -258,7 +258,7 @@ describe('SendEnterAmount', () => {
258
258
  </Provider>
259
259
  )
260
260
 
261
- expect(getByText('MiniPay')).toBeTruthy()
261
+ expect(getByText('sendEnterAmountScreen.miniPayFilterChip')).toBeTruthy()
262
262
 
263
263
  const tokenBottomSheet = getAllByTestId('TokenBottomSheet')[0]
264
264
  const tokens = within(tokenBottomSheet).getAllByTestId('TokenBalanceItem')
@@ -291,7 +291,7 @@ describe('SendEnterAmount', () => {
291
291
  </Provider>
292
292
  )
293
293
 
294
- fireEvent.press(getByText('MiniPay'))
294
+ fireEvent.press(getByText('sendEnterAmountScreen.miniPayFilterChip'))
295
295
 
296
296
  const tokenBottomSheet = getAllByTestId('TokenBottomSheet')[0]
297
297
  const tokens = within(tokenBottomSheet).getAllByTestId('TokenBalanceItem')
@@ -305,7 +305,7 @@ describe('SendEnterAmount', () => {
305
305
  </Provider>
306
306
  )
307
307
 
308
- expect(queryByText('MiniPay')).toBeFalsy()
308
+ expect(queryByText('sendEnterAmountScreen.miniPayFilterChip')).toBeFalsy()
309
309
  })
310
310
 
311
311
  it('should include isMiniPayRecipient in send_amount_continue analytics', async () => {
@@ -0,0 +1,107 @@
1
+ import { act, fireEvent, render } from '@testing-library/react-native'
2
+ import * as React from 'react'
3
+ import Share from 'react-native-share'
4
+ import { Provider } from 'react-redux'
5
+ import { showError } from 'src/alert/actions'
6
+ import AppAnalytics from 'src/analytics/AppAnalytics'
7
+ import { SendEvents } from 'src/analytics/Events'
8
+ import { ErrorMessages } from 'src/app/ErrorMessages'
9
+ import { Screens } from 'src/navigator/Screens'
10
+ import { RecipientType } from 'src/recipients/recipient'
11
+ import SendInvite from 'src/send/SendInvite'
12
+ import { createMockStore, getMockStackScreenProps } from 'test/utils'
13
+ import { mockInvitableRecipient3 } from 'test/values'
14
+
15
+ const shareUrl = 'https://example.test/invite'
16
+
17
+ const mockScreenProps = () =>
18
+ getMockStackScreenProps(Screens.SendInvite, {
19
+ recipient: mockInvitableRecipient3,
20
+ shareUrl,
21
+ })
22
+
23
+ describe('SendInvite', () => {
24
+ beforeEach(() => {
25
+ jest.clearAllMocks()
26
+ })
27
+
28
+ it('opens the share sheet, tracks the press analytic, and stays on the screen after the sheet closes', async () => {
29
+ jest
30
+ .mocked(Share.open)
31
+ .mockResolvedValueOnce({ success: true, dismissedAction: false, message: '' })
32
+
33
+ const store = createMockStore({})
34
+ const { getByTestId } = render(
35
+ <Provider store={store}>
36
+ <SendInvite {...mockScreenProps()} />
37
+ </Provider>
38
+ )
39
+
40
+ await act(async () => {
41
+ fireEvent.press(getByTestId('SendInvite/ShareButton'))
42
+ })
43
+
44
+ expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_invite_press, {
45
+ recipientType: RecipientType.PhoneNumber,
46
+ })
47
+ expect(Share.open).toHaveBeenCalledWith(
48
+ expect.objectContaining({
49
+ message: expect.stringContaining(shareUrl),
50
+ url: shareUrl,
51
+ failOnCancel: false,
52
+ })
53
+ )
54
+ })
55
+
56
+ it('dispatches an error toast when the share sheet fails', async () => {
57
+ jest.mocked(Share.open).mockRejectedValueOnce(new Error('no share providers'))
58
+
59
+ const store = createMockStore({})
60
+ const { getByTestId } = render(
61
+ <Provider store={store}>
62
+ <SendInvite {...mockScreenProps()} />
63
+ </Provider>
64
+ )
65
+
66
+ await act(async () => {
67
+ fireEvent.press(getByTestId('SendInvite/ShareButton'))
68
+ })
69
+
70
+ expect(store.getActions()).toContainEqual(showError(ErrorMessages.SHARE_INVITE_FAILED))
71
+ })
72
+
73
+ it('renders the contact name in the title when the recipient has one', () => {
74
+ const store = createMockStore({})
75
+ const { getByText } = render(
76
+ <Provider store={store}>
77
+ <SendInvite {...mockScreenProps()} />
78
+ </Provider>
79
+ )
80
+
81
+ expect(
82
+ getByText(`sendInvite.title, {"contact":"${mockInvitableRecipient3.name}"}`)
83
+ ).toBeTruthy()
84
+ })
85
+
86
+ it('falls back to the phone number in the title when the recipient has no name', () => {
87
+ const namelessRecipient = {
88
+ displayNumber: mockInvitableRecipient3.displayNumber,
89
+ e164PhoneNumber: mockInvitableRecipient3.e164PhoneNumber,
90
+ recipientType: mockInvitableRecipient3.recipientType,
91
+ }
92
+ const screenProps = getMockStackScreenProps(Screens.SendInvite, {
93
+ recipient: namelessRecipient,
94
+ shareUrl,
95
+ })
96
+ const store = createMockStore({})
97
+ const { getByText } = render(
98
+ <Provider store={store}>
99
+ <SendInvite {...screenProps} />
100
+ </Provider>
101
+ )
102
+
103
+ expect(
104
+ getByText(`sendInvite.title, {"contact":"${mockInvitableRecipient3.displayNumber}"}`)
105
+ ).toBeTruthy()
106
+ })
107
+ })
@@ -0,0 +1,99 @@
1
+ import { NativeStackScreenProps } from '@react-navigation/native-stack'
2
+ import React from 'react'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'
5
+ import { SafeAreaView } from 'react-native-safe-area-context'
6
+ import Share from 'react-native-share'
7
+ import { showError } from 'src/alert/actions'
8
+ import AppAnalytics from 'src/analytics/AppAnalytics'
9
+ import { SendEvents } from 'src/analytics/Events'
10
+ import { ErrorMessages } from 'src/app/ErrorMessages'
11
+ import Button, { BtnSizes } from 'src/components/Button'
12
+ import { inviteModal } from 'src/images/Images'
13
+ import { headerWithBackButton } from 'src/navigator/Headers'
14
+ import { Screens } from 'src/navigator/Screens'
15
+ import { StackParamList } from 'src/navigator/types'
16
+ import { getDisplayName } from 'src/recipients/recipient'
17
+ import { useDispatch } from 'src/redux/hooks'
18
+ import { typeScale } from 'src/styles/fonts'
19
+ import { Spacing } from 'src/styles/styles'
20
+ import Logger from 'src/utils/Logger'
21
+
22
+ type Props = NativeStackScreenProps<StackParamList, Screens.SendInvite>
23
+
24
+ function SendInvite({ route }: Props) {
25
+ const { t } = useTranslation()
26
+ const dispatch = useDispatch()
27
+ const { recipient, shareUrl } = route.params
28
+ const contact = getDisplayName(recipient, t)
29
+
30
+ const handleShareInvite = async () => {
31
+ AppAnalytics.track(SendEvents.send_select_recipient_invite_press, {
32
+ recipientType: recipient.recipientType,
33
+ })
34
+
35
+ try {
36
+ await Share.open({
37
+ message: t('inviteWithSmsMessage.shareMessage', { shareUrl }),
38
+ url: shareUrl,
39
+ failOnCancel: false,
40
+ })
41
+ } catch (error) {
42
+ Logger.error('SendInvite', 'Share sheet failed', error)
43
+ dispatch(showError(ErrorMessages.SHARE_INVITE_FAILED))
44
+ }
45
+ }
46
+
47
+ return (
48
+ <SafeAreaView style={styles.container} edges={['bottom']}>
49
+ <ScrollView contentContainerStyle={styles.scrollContent}>
50
+ <View style={styles.imageContainer}>
51
+ <Image source={inviteModal} resizeMode="contain" />
52
+ </View>
53
+ <Text style={styles.title}>{t('sendInvite.title', { contact })}</Text>
54
+ <Text style={styles.body}>{t('sendInvite.body')}</Text>
55
+ </ScrollView>
56
+ <Button
57
+ testID="SendInvite/ShareButton"
58
+ style={styles.button}
59
+ onPress={handleShareInvite}
60
+ text={t('sendInvite.cta')}
61
+ size={BtnSizes.FULL}
62
+ />
63
+ </SafeAreaView>
64
+ )
65
+ }
66
+
67
+ SendInvite.navigationOptions = headerWithBackButton
68
+
69
+ const styles = StyleSheet.create({
70
+ container: {
71
+ flex: 1,
72
+ justifyContent: 'space-between',
73
+ },
74
+ scrollContent: {
75
+ padding: Spacing.Thick24,
76
+ },
77
+ imageContainer: {
78
+ alignItems: 'center',
79
+ paddingTop: Spacing.Thick24,
80
+ paddingBottom: Spacing.Regular16,
81
+ },
82
+ title: {
83
+ ...typeScale.titleMedium,
84
+ textAlign: 'center',
85
+ marginTop: Spacing.Large32,
86
+ paddingHorizontal: Spacing.Regular16,
87
+ },
88
+ body: {
89
+ ...typeScale.bodyMedium,
90
+ textAlign: 'center',
91
+ marginTop: Spacing.Regular16,
92
+ paddingHorizontal: Spacing.Regular16,
93
+ },
94
+ button: {
95
+ padding: Spacing.Thick24,
96
+ },
97
+ })
98
+
99
+ export default SendInvite
@@ -203,7 +203,7 @@ describe('SendSelectRecipient', () => {
203
203
  })
204
204
  expect(getByTestId('SelectRecipient/NoResults')).toBeTruthy()
205
205
  })
206
- it('navigates to send amount when search result next button is pressed', async () => {
206
+ it('navigates to send amount when a verified phone recipient is tapped in search results', async () => {
207
207
  jest
208
208
  .mocked(getRecipientVerificationStatus)
209
209
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -224,15 +224,6 @@ describe('SendSelectRecipient', () => {
224
224
  fireEvent.press(getByTestId('RecipientItem'))
225
225
  })
226
226
 
227
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
228
- expect(getByTestId('SendOrInviteButton')).toHaveTextContent(
229
- 'sendSelectRecipient.buttons.send',
230
- { exact: false }
231
- )
232
-
233
- await act(() => {
234
- fireEvent.press(getByTestId('SendOrInviteButton'))
235
- })
236
227
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
237
228
  recipientType: RecipientType.PhoneNumber,
238
229
  })
@@ -246,7 +237,7 @@ describe('SendSelectRecipient', () => {
246
237
  isMiniPayRecipient: false,
247
238
  })
248
239
  })
249
- it('navigates to send amount when address recipient is pressed', async () => {
240
+ it('navigates to send amount when an address is tapped and the user phone number is not verified', async () => {
250
241
  const store = createMockStore(defaultStore)
251
242
 
252
243
  const { getByTestId } = render(
@@ -263,15 +254,6 @@ describe('SendSelectRecipient', () => {
263
254
  fireEvent.press(getByTestId('RecipientItem'))
264
255
  })
265
256
 
266
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
267
- expect(getByTestId('SendOrInviteButton')).toHaveTextContent(
268
- 'sendSelectRecipient.buttons.send',
269
- { exact: false }
270
- )
271
-
272
- await act(() => {
273
- fireEvent.press(getByTestId('SendOrInviteButton'))
274
- })
275
257
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
276
258
  recipientType: RecipientType.Address,
277
259
  })
@@ -285,14 +267,14 @@ describe('SendSelectRecipient', () => {
285
267
  })
286
268
  })
287
269
 
288
- it('does not show unknown address info text when searching for known app address', async () => {
270
+ it('dispatches address verification when an address is tapped and the user phone number is verified', async () => {
289
271
  jest
290
272
  .mocked(getRecipientVerificationStatus)
291
273
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
292
274
 
293
275
  const store = createMockStore(storeWithPhoneVerified)
294
276
 
295
- const { getByTestId, queryByTestId } = render(
277
+ const { getByTestId } = render(
296
278
  <Provider store={store}>
297
279
  <SendSelectRecipient {...mockScreenProps({})} />
298
280
  </Provider>
@@ -316,17 +298,15 @@ describe('SendSelectRecipient', () => {
316
298
  })
317
299
 
318
300
  expect(store.getActions()).toEqual([fetchAddressVerification(mockAccount2.toLowerCase())])
319
- expect(queryByTestId('UnknownAddressInfo')).toBeFalsy()
320
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
321
301
  })
322
- it('does not show unknown address info text when searching for phone number', async () => {
302
+ it('does not navigate when an unverified phone recipient is tapped and no share URL is configured', async () => {
323
303
  jest
324
304
  .mocked(getRecipientVerificationStatus)
325
305
  .mockReturnValue(RecipientVerificationStatus.UNVERIFIED)
326
306
 
327
307
  const store = createMockStore(storeWithPhoneVerified)
328
308
 
329
- const { getByTestId, queryByTestId } = render(
309
+ const { getByTestId } = render(
330
310
  <Provider store={store}>
331
311
  <SendSelectRecipient {...mockScreenProps({})} />
332
312
  </Provider>
@@ -347,11 +327,20 @@ describe('SendSelectRecipient', () => {
347
327
  })
348
328
 
349
329
  expect(store.getActions()).toEqual([fetchAddressesAndValidate(mockE164Number2Invite)])
350
- expect(queryByTestId('UnknownAddressInfo')).toBeFalsy()
351
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
330
+ expect(navigate).not.toHaveBeenCalled()
352
331
  })
353
332
 
354
- it('shows unknown address info text when searching for unknown address after making address verification request', async () => {
333
+ it('navigates to the invite screen when an unverified phone number is tapped and share URL is configured', async () => {
334
+ const shareUrl = 'https://example.test/invite'
335
+ jest.mocked(getAppConfig).mockReturnValue({
336
+ displayName: 'Test App',
337
+ deepLinkUrlScheme: 'testapp',
338
+ registryName: 'test',
339
+ experimental: {
340
+ phoneNumberVerification: true,
341
+ inviteFriends: { shareUrl },
342
+ },
343
+ })
355
344
  jest
356
345
  .mocked(getRecipientVerificationStatus)
357
346
  .mockReturnValue(RecipientVerificationStatus.UNVERIFIED)
@@ -363,31 +352,33 @@ describe('SendSelectRecipient', () => {
363
352
  <SendSelectRecipient {...mockScreenProps({})} />
364
353
  </Provider>
365
354
  )
366
- await waitFor(() => {
367
- expect(getByTestId('SendSelectRecipientSearchInput')).toBeTruthy()
368
- })
369
- const searchInput = getByTestId('SendSelectRecipientSearchInput')
370
355
 
356
+ const searchInput = getByTestId('SendSelectRecipientSearchInput')
371
357
  await act(() => {
372
- fireEvent.changeText(searchInput, mockAccount2)
358
+ fireEvent.changeText(searchInput, mockE164Number2Invite)
373
359
  })
374
-
375
- // ensure its an address recipient (not an address that's tied to a contact)
376
- expect(getByTestId('RecipientItem')).toHaveTextContent(
377
- 'feedItemAddress, {"address":"0x1ff4...bc42"}',
378
- { exact: false }
379
- )
380
-
381
360
  await act(() => {
382
361
  fireEvent.press(getByTestId('RecipientItem'))
383
362
  })
384
363
 
385
- expect(store.getActions()).toEqual([fetchAddressVerification(mockAccount2.toLowerCase())])
386
- expect(getByTestId('UnknownAddressInfo')).toBeTruthy()
387
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
364
+ expect(navigate).toHaveBeenCalledWith(Screens.SendInvite, {
365
+ recipient: expect.objectContaining({
366
+ e164PhoneNumber: mockE164Number2Invite,
367
+ recipientType: RecipientType.PhoneNumber,
368
+ }),
369
+ shareUrl,
370
+ })
371
+
372
+ // Search text is preserved so the user can return to the same picker state.
373
+ expect(searchInput.props.value).toBe(mockE164Number2Invite)
388
374
  })
389
- it('shows unknown address info text and skips CPV request when searching for any address if PN not verified', async () => {
390
- const store = createMockStore(defaultStore)
375
+
376
+ it('navigates and dispatches address verification when an unknown address is tapped and the user phone number is verified', async () => {
377
+ jest
378
+ .mocked(getRecipientVerificationStatus)
379
+ .mockReturnValue(RecipientVerificationStatus.UNVERIFIED)
380
+
381
+ const store = createMockStore(storeWithPhoneVerified)
391
382
 
392
383
  const { getByTestId } = render(
393
384
  <Provider store={store}>
@@ -413,16 +404,14 @@ describe('SendSelectRecipient', () => {
413
404
  fireEvent.press(getByTestId('RecipientItem'))
414
405
  })
415
406
 
416
- expect(store.getActions()).toEqual([])
417
- expect(getByTestId('UnknownAddressInfo')).toBeTruthy()
418
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
407
+ expect(store.getActions()).toEqual([fetchAddressVerification(mockAccount2.toLowerCase())])
408
+ expect(navigate).toHaveBeenCalledWith(
409
+ Screens.SendEnterAmount,
410
+ expect.objectContaining({ origin: SendOrigin.AppSendFlow })
411
+ )
419
412
  })
420
- it('shows unknown address info text and send button when searching for address with cached phone number but no longer connected to the phone number', async () => {
421
- jest
422
- .mocked(getRecipientVerificationStatus)
423
- .mockReturnValue(RecipientVerificationStatus.UNVERIFIED)
424
-
425
- const store = createMockStore(storeWithPhoneVerified)
413
+ it('skips verification request for an address when the user phone number is not verified', async () => {
414
+ const store = createMockStore(defaultStore)
426
415
 
427
416
  const { getByTestId } = render(
428
417
  <Provider store={store}>
@@ -435,22 +424,25 @@ describe('SendSelectRecipient', () => {
435
424
  const searchInput = getByTestId('SendSelectRecipientSearchInput')
436
425
 
437
426
  await act(() => {
438
- fireEvent.changeText(searchInput, mockAccount)
427
+ fireEvent.changeText(searchInput, mockAccount2)
439
428
  })
440
429
 
441
- expect(getByTestId('RecipientItem')).toHaveTextContent(mockRecipient.name, { exact: false })
442
- expect(getByTestId('RecipientItem')).toHaveTextContent(mockRecipient.displayNumber, {
443
- exact: false,
444
- })
430
+ // ensure its an address recipient (not an address that's tied to a contact)
431
+ expect(getByTestId('RecipientItem')).toHaveTextContent(
432
+ 'feedItemAddress, {"address":"0x1ff4...bc42"}',
433
+ { exact: false }
434
+ )
445
435
 
446
436
  await act(() => {
447
437
  fireEvent.press(getByTestId('RecipientItem'))
448
438
  })
449
439
 
450
- expect(store.getActions()).toEqual([fetchAddressVerification(mockAccount)])
451
- expect(getByTestId('UnknownAddressInfo')).toBeTruthy()
452
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
453
- expect(getByTestId('SendOrInviteButton')).toHaveTextContent('send', { exact: false })
440
+ expect(store.getActions()).toEqual([])
441
+ // Navigation still proceeds — status is treated as UNVERIFIED locally.
442
+ expect(navigate).toHaveBeenCalledWith(
443
+ Screens.SendEnterAmount,
444
+ expect.objectContaining({ origin: SendOrigin.AppSendFlow })
445
+ )
454
446
  })
455
447
  it('shows paste button if clipboard has address content', async () => {
456
448
  const store = createMockStore(defaultStore)
@@ -476,7 +468,7 @@ describe('SendSelectRecipient', () => {
476
468
  await expect(pasteButtonAfterPress).rejects.toThrow()
477
469
  })
478
470
 
479
- it('navigates to send amount when phone number recipient with single address', async () => {
471
+ it('navigates to send amount when a verified phone recipient with a single address is tapped', async () => {
480
472
  jest
481
473
  .mocked(getRecipientVerificationStatus)
482
474
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -505,11 +497,6 @@ describe('SendSelectRecipient', () => {
505
497
  fireEvent.press(getByTestId('RecipientItem'))
506
498
  })
507
499
 
508
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
509
-
510
- await act(() => {
511
- fireEvent.press(getByTestId('SendOrInviteButton'))
512
- })
513
500
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
514
501
  recipientType: RecipientType.PhoneNumber,
515
502
  })
@@ -527,7 +514,7 @@ describe('SendSelectRecipient', () => {
527
514
  isMiniPayRecipient: false,
528
515
  })
529
516
  })
530
- it('navigates to send amount with isMiniPayRecipient when address is verified by minipay', async () => {
517
+ it('navigates with isMiniPayRecipient when address is verified by minipay', async () => {
531
518
  jest
532
519
  .mocked(getRecipientVerificationStatus)
533
520
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -556,9 +543,6 @@ describe('SendSelectRecipient', () => {
556
543
  await act(() => {
557
544
  fireEvent.press(getByTestId('RecipientItem'))
558
545
  })
559
- await act(() => {
560
- fireEvent.press(getByTestId('SendOrInviteButton'))
561
- })
562
546
 
563
547
  expect(navigate).toHaveBeenCalledWith(Screens.SendEnterAmount, {
564
548
  isFromScan: false,
@@ -574,7 +558,7 @@ describe('SendSelectRecipient', () => {
574
558
  isMiniPayRecipient: true,
575
559
  })
576
560
  })
577
- it('navigates to secure send flow when phone number recipient with multiple addresses, first time seeing it', async () => {
561
+ it('navigates to secure send flow when phone recipient has multiple addresses, first time seeing it', async () => {
578
562
  jest
579
563
  .mocked(getRecipientVerificationStatus)
580
564
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -609,11 +593,6 @@ describe('SendSelectRecipient', () => {
609
593
  fireEvent.press(getByTestId('RecipientItem'))
610
594
  })
611
595
 
612
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
613
-
614
- await act(() => {
615
- fireEvent.press(getByTestId('SendOrInviteButton'))
616
- })
617
596
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
618
597
  recipientType: RecipientType.PhoneNumber,
619
598
  })
@@ -624,7 +603,7 @@ describe('SendSelectRecipient', () => {
624
603
  origin: SendOrigin.AppSendFlow,
625
604
  })
626
605
  })
627
- it('navigates to send enter amount when phone number recipient with multiple addresses, already done secure send', async () => {
606
+ it('navigates to send amount when phone recipient has multiple addresses and secure send was already done', async () => {
628
607
  jest
629
608
  .mocked(getRecipientVerificationStatus)
630
609
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -662,11 +641,6 @@ describe('SendSelectRecipient', () => {
662
641
  fireEvent.press(getByTestId('RecipientItem'))
663
642
  })
664
643
 
665
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
666
-
667
- await act(() => {
668
- fireEvent.press(getByTestId('SendOrInviteButton'))
669
- })
670
644
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
671
645
  recipientType: RecipientType.PhoneNumber,
672
646
  })
@@ -723,11 +697,6 @@ describe('SendSelectRecipient', () => {
723
697
  fireEvent.press(getByTestId('RecipientItem'))
724
698
  })
725
699
 
726
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
727
-
728
- await act(() => {
729
- fireEvent.press(getByTestId('SendOrInviteButton'))
730
- })
731
700
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
732
701
  recipientType: RecipientType.Address,
733
702
  })
@@ -789,11 +758,6 @@ describe('SendSelectRecipient', () => {
789
758
  fireEvent.press(getByTestId('RecipientItem'))
790
759
  })
791
760
 
792
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
793
-
794
- await act(() => {
795
- fireEvent.press(getByTestId('SendOrInviteButton'))
796
- })
797
761
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
798
762
  recipientType: RecipientType.Address,
799
763
  })
@@ -1,18 +1,15 @@
1
1
  import { NativeStackScreenProps } from '@react-navigation/native-stack'
2
- import React, { useState } from 'react'
2
+ import React, { useEffect, useState } from 'react'
3
3
  import { useTranslation } from 'react-i18next'
4
4
  import { StyleSheet, Text, View } from 'react-native'
5
5
  import { getFontScaleSync } from 'react-native-device-info'
6
6
  import { SafeAreaView } from 'react-native-safe-area-context'
7
- import Share, { ShareSingleOptions, Social } from 'react-native-share'
8
7
  import { isAddressFormat } from 'src/account/utils'
9
8
  import AppAnalytics from 'src/analytics/AppAnalytics'
10
9
  import { SendEvents } from 'src/analytics/Events'
11
10
  import { SendOrigin } from 'src/analytics/types'
12
11
  import { getAppConfig } from 'src/appConfig'
13
12
  import BackButton from 'src/components/BackButton'
14
- import Button, { BtnSizes } from 'src/components/Button'
15
- import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification'
16
13
  import KeyboardAwareScrollView from 'src/components/KeyboardAwareScrollView'
17
14
  import CustomHeader from 'src/components/header/CustomHeader'
18
15
  import CircledIcon from 'src/icons/CircledIcon'
@@ -42,7 +39,6 @@ import colors from 'src/styles/colors'
42
39
  import { typeScale } from 'src/styles/fonts'
43
40
  import { Spacing } from 'src/styles/styles'
44
41
  import variables from 'src/styles/variables'
45
- import Logger from 'src/utils/Logger'
46
42
 
47
43
  type Props = NativeStackScreenProps<StackParamList, Screens.SendSelectRecipient>
48
44
 
@@ -167,45 +163,6 @@ const getStartedStyles = StyleSheet.create({
167
163
  },
168
164
  })
169
165
 
170
- function SendOrInviteButton({
171
- recipient,
172
- recipientVerificationStatus,
173
- shareUrl,
174
- onPress,
175
- }: {
176
- recipient: Recipient | null
177
- recipientVerificationStatus: RecipientVerificationStatus
178
- shareUrl: string | null
179
- onPress: (shouldInviteRecipient: boolean) => void
180
- }) {
181
- const { t } = useTranslation()
182
-
183
- const sendOrInviteButtonDisabled =
184
- (!!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN) ||
185
- // If the phone number is present and unverified and no share URL is found, disable the send/invite button
186
- (recipient?.recipientType === RecipientType.PhoneNumber &&
187
- recipientVerificationStatus === RecipientVerificationStatus.UNVERIFIED &&
188
- !shareUrl)
189
-
190
- const shouldInviteRecipient =
191
- !sendOrInviteButtonDisabled &&
192
- recipient?.recipientType === RecipientType.PhoneNumber &&
193
- recipientVerificationStatus === RecipientVerificationStatus.UNVERIFIED
194
- return (
195
- <Button
196
- testID="SendOrInviteButton"
197
- style={styles.sendOrInviteButton}
198
- onPress={() => onPress(shouldInviteRecipient)}
199
- disabled={sendOrInviteButtonDisabled}
200
- text={
201
- shouldInviteRecipient
202
- ? t('sendSelectRecipient.buttons.invite')
203
- : t('sendSelectRecipient.buttons.send')
204
- }
205
- size={BtnSizes.FULL}
206
- />
207
- )
208
- }
209
166
  enum SelectRecipientView {
210
167
  Recent = 'Recent',
211
168
  Contacts = 'Contacts',
@@ -222,19 +179,14 @@ function SendSelectRecipient({ route }: Props) {
222
179
  const forceTokenId = route.params?.forceTokenId
223
180
  const defaultTokenIdOverride = route.params?.defaultTokenIdOverride
224
181
 
225
- const [showSendOrInviteButton, setShowSendOrInviteButton] = useState(false)
226
-
227
182
  const [showSearchResults, setShowSearchResults] = useState(false)
228
183
 
229
184
  const [activeView, setActiveView] = useState(SelectRecipientView.Recent)
230
185
 
231
186
  const onSearch = (searchQuery: string) => {
232
- // Always unset the selected recipient and hide the send/invite button
233
- // when the search query is changed in order to prevent edge cases
234
- // where the button appears but is bound to a recipient that is
235
- // not present on the page.
187
+ // Clear any in-flight selection so a stale recipient can't auto-navigate
188
+ // once the user starts typing a different query.
236
189
  unsetSelectedRecipient()
237
- setShowSendOrInviteButton(false)
238
190
  setShowSearchResults(!!searchQuery)
239
191
  }
240
192
  const { contactRecipients, recentRecipients } = useSendRecipients()
@@ -243,17 +195,31 @@ function SendSelectRecipient({ route }: Props) {
243
195
  const { recipientVerificationStatus, recipient, setSelectedRecipient, unsetSelectedRecipient } =
244
196
  useFetchRecipientVerificationStatus()
245
197
 
246
- const showUnknownAddressInfo =
247
- showSendOrInviteButton &&
248
- showSearchResults &&
249
- recipient &&
250
- recipient.recipientType !== RecipientType.PhoneNumber &&
251
- recipientVerificationStatus === RecipientVerificationStatus.UNVERIFIED
198
+ useEffect(() => {
199
+ // Auto-navigate once verification resolves. The picker stays mounted so the
200
+ // user's search text and selection are preserved when they come back.
201
+ if (!recipient || recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN) {
202
+ return
203
+ }
252
204
 
253
- const setSelectedRecipientWrapper = (selectedRecipient: Recipient) => {
254
- setSelectedRecipient(selectedRecipient)
255
- setShowSendOrInviteButton(true)
256
- }
205
+ const isUnverifiedPhone =
206
+ recipient.recipientType === RecipientType.PhoneNumber &&
207
+ recipientVerificationStatus === RecipientVerificationStatus.UNVERIFIED
208
+
209
+ if (isUnverifiedPhone) {
210
+ if (shareUrl) {
211
+ navigate(Screens.SendInvite, { recipient, shareUrl })
212
+ }
213
+ // Without shareUrl there's no invite flow and no send flow for an
214
+ // unverified phone — stay on the picker so the user can pick someone else.
215
+ return
216
+ }
217
+
218
+ AppAnalytics.track(SendEvents.send_select_recipient_send_press, {
219
+ recipientType: recipient.recipientType,
220
+ })
221
+ nextScreen(recipient)
222
+ }, [recipient, recipientVerificationStatus, shareUrl])
257
223
 
258
224
  const onContactsPermissionGranted = () => {
259
225
  dispatch(importContacts())
@@ -268,7 +234,6 @@ function SendSelectRecipient({ route }: Props) {
268
234
  AppAnalytics.track(SendEvents.send_select_recipient_recent_press, {
269
235
  recipientType: recentRecipient.recipientType,
270
236
  })
271
- setSelectedRecipient(recentRecipient)
272
237
  nextScreen(recentRecipient)
273
238
  }
274
239
 
@@ -320,38 +285,6 @@ function SendSelectRecipient({ route }: Props) {
320
285
  })
321
286
  }
322
287
 
323
- const onPressSendOrInvite = async (shouldInviteRecipient: boolean) => {
324
- if (!recipient) return
325
-
326
- // Invites
327
- if (shouldInviteRecipient) {
328
- if (!shareUrl) {
329
- Logger.warn('SendSelectRecipient', 'No share URL found for invite with SMS')
330
- return
331
- }
332
-
333
- const shareOptions: ShareSingleOptions = {
334
- social: Social.Sms,
335
- recipient: recipient.e164PhoneNumber,
336
- message: t('inviteWithSmsMessage.shareMessage', { shareUrl }),
337
- }
338
-
339
- await Share.shareSingle(shareOptions)
340
-
341
- AppAnalytics.track(SendEvents.send_select_recipient_invite_press, {
342
- recipientType: recipient.recipientType,
343
- })
344
-
345
- return
346
- }
347
-
348
- // Sends
349
- AppAnalytics.track(SendEvents.send_select_recipient_send_press, {
350
- recipientType: recipient.recipientType,
351
- })
352
- nextScreen(recipient)
353
- }
354
-
355
288
  const renderSearchResults = () => {
356
289
  if (mergedRecipients.length) {
357
290
  return (
@@ -360,7 +293,7 @@ function SendSelectRecipient({ route }: Props) {
360
293
  <RecipientPicker
361
294
  testID={'SelectRecipient/AllRecipientsPicker'}
362
295
  recipients={mergedRecipients}
363
- onSelectRecipient={setSelectedRecipientWrapper}
296
+ onSelectRecipient={setSelectedRecipient}
364
297
  selectedRecipient={recipient}
365
298
  isSelectedRecipientLoading={
366
299
  !!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
@@ -407,7 +340,7 @@ function SendSelectRecipient({ route }: Props) {
407
340
  <RecipientPicker
408
341
  testID={'SelectRecipient/ContactRecipientPicker'}
409
342
  recipients={contactRecipients}
410
- onSelectRecipient={setSelectedRecipientWrapper}
343
+ onSelectRecipient={setSelectedRecipient}
411
344
  selectedRecipient={recipient}
412
345
  isSelectedRecipientLoading={
413
346
  !!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
@@ -437,22 +370,6 @@ function SendSelectRecipient({ route }: Props) {
437
370
  </>
438
371
  )}
439
372
  </KeyboardAwareScrollView>
440
- {showUnknownAddressInfo && (
441
- <InLineNotification
442
- variant={NotificationVariant.Info}
443
- description={t('sendSelectRecipient.unknownAddressInfo')}
444
- testID="UnknownAddressInfo"
445
- style={styles.unknownAddressInfo}
446
- />
447
- )}
448
- {showSendOrInviteButton && (
449
- <SendOrInviteButton
450
- recipient={recipient}
451
- recipientVerificationStatus={recipientVerificationStatus}
452
- onPress={onPressSendOrInvite}
453
- shareUrl={shareUrl}
454
- />
455
- )}
456
373
  </SafeAreaView>
457
374
  )
458
375
  }
@@ -494,14 +411,6 @@ const styles = StyleSheet.create({
494
411
  padding: Spacing.Regular16,
495
412
  textAlign: 'center',
496
413
  },
497
- unknownAddressInfo: {
498
- margin: Spacing.Regular16,
499
- marginBottom: variables.contentPadding,
500
- },
501
- sendOrInviteButton: {
502
- margin: Spacing.Regular16,
503
- marginTop: variables.contentPadding,
504
- },
505
414
  })
506
415
 
507
416
  export default SendSelectRecipient
@@ -1,3 +1,4 @@
1
+ import { useTranslation } from 'react-i18next'
1
2
  import { BooleanFilterChip } from 'src/components/FilterChipsCarousel'
2
3
  import { getDynamicConfigParams } from 'src/statsig'
3
4
  import { DynamicConfigs } from 'src/statsig/constants'
@@ -18,6 +19,8 @@ export default function useSendFilterChips({
18
19
  filterChips: BooleanFilterChip<TokenBalance>[]
19
20
  defaultToken: TokenBalance | undefined
20
21
  } {
22
+ const { t } = useTranslation()
23
+
21
24
  const { miniPayTokenIds: configTokenIds } = getDynamicConfigParams(
22
25
  DynamicConfigs[StatsigDynamicConfigs.SEND_CONFIG]
23
26
  )
@@ -27,7 +30,7 @@ export default function useSendFilterChips({
27
30
  ? [
28
31
  {
29
32
  id: 'minipay',
30
- name: 'MiniPay',
33
+ name: t('sendEnterAmountScreen.miniPayFilterChip'),
31
34
  filterFn: (token: TokenBalance) => miniPayTokenIds.includes(token.tokenId),
32
35
  isSelected: true,
33
36
  },