wallet-stack 1.0.0-alpha.131 → 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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallet-stack",
3
- "version": "1.0.0-alpha.131",
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')}
@@ -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
@@ -2,7 +2,6 @@ import Clipboard from '@react-native-clipboard/clipboard'
2
2
  import { act, fireEvent, render, waitFor } from '@testing-library/react-native'
3
3
  import * as React from 'react'
4
4
  import { Provider } from 'react-redux'
5
- import Share from 'react-native-share'
6
5
  import AppAnalytics from 'src/analytics/AppAnalytics'
7
6
  import { SendEvents } from 'src/analytics/Events'
8
7
  import { SendOrigin } from 'src/analytics/types'
@@ -204,7 +203,7 @@ describe('SendSelectRecipient', () => {
204
203
  })
205
204
  expect(getByTestId('SelectRecipient/NoResults')).toBeTruthy()
206
205
  })
207
- 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 () => {
208
207
  jest
209
208
  .mocked(getRecipientVerificationStatus)
210
209
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -225,15 +224,6 @@ describe('SendSelectRecipient', () => {
225
224
  fireEvent.press(getByTestId('RecipientItem'))
226
225
  })
227
226
 
228
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
229
- expect(getByTestId('SendOrInviteButton')).toHaveTextContent(
230
- 'sendSelectRecipient.buttons.send',
231
- { exact: false }
232
- )
233
-
234
- await act(() => {
235
- fireEvent.press(getByTestId('SendOrInviteButton'))
236
- })
237
227
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
238
228
  recipientType: RecipientType.PhoneNumber,
239
229
  })
@@ -247,7 +237,7 @@ describe('SendSelectRecipient', () => {
247
237
  isMiniPayRecipient: false,
248
238
  })
249
239
  })
250
- 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 () => {
251
241
  const store = createMockStore(defaultStore)
252
242
 
253
243
  const { getByTestId } = render(
@@ -264,15 +254,6 @@ describe('SendSelectRecipient', () => {
264
254
  fireEvent.press(getByTestId('RecipientItem'))
265
255
  })
266
256
 
267
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
268
- expect(getByTestId('SendOrInviteButton')).toHaveTextContent(
269
- 'sendSelectRecipient.buttons.send',
270
- { exact: false }
271
- )
272
-
273
- await act(() => {
274
- fireEvent.press(getByTestId('SendOrInviteButton'))
275
- })
276
257
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
277
258
  recipientType: RecipientType.Address,
278
259
  })
@@ -286,14 +267,14 @@ describe('SendSelectRecipient', () => {
286
267
  })
287
268
  })
288
269
 
289
- 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 () => {
290
271
  jest
291
272
  .mocked(getRecipientVerificationStatus)
292
273
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
293
274
 
294
275
  const store = createMockStore(storeWithPhoneVerified)
295
276
 
296
- const { getByTestId, queryByTestId } = render(
277
+ const { getByTestId } = render(
297
278
  <Provider store={store}>
298
279
  <SendSelectRecipient {...mockScreenProps({})} />
299
280
  </Provider>
@@ -317,17 +298,15 @@ describe('SendSelectRecipient', () => {
317
298
  })
318
299
 
319
300
  expect(store.getActions()).toEqual([fetchAddressVerification(mockAccount2.toLowerCase())])
320
- expect(queryByTestId('UnknownAddressInfo')).toBeFalsy()
321
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
322
301
  })
323
- 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 () => {
324
303
  jest
325
304
  .mocked(getRecipientVerificationStatus)
326
305
  .mockReturnValue(RecipientVerificationStatus.UNVERIFIED)
327
306
 
328
307
  const store = createMockStore(storeWithPhoneVerified)
329
308
 
330
- const { getByTestId, queryByTestId } = render(
309
+ const { getByTestId } = render(
331
310
  <Provider store={store}>
332
311
  <SendSelectRecipient {...mockScreenProps({})} />
333
312
  </Provider>
@@ -348,11 +327,10 @@ describe('SendSelectRecipient', () => {
348
327
  })
349
328
 
350
329
  expect(store.getActions()).toEqual([fetchAddressesAndValidate(mockE164Number2Invite)])
351
- expect(queryByTestId('UnknownAddressInfo')).toBeFalsy()
352
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
330
+ expect(navigate).not.toHaveBeenCalled()
353
331
  })
354
332
 
355
- it('opens the platform share sheet and tracks the press analytic before awaiting when inviting an unverified phone number', async () => {
333
+ it('navigates to the invite screen when an unverified phone number is tapped and share URL is configured', async () => {
356
334
  const shareUrl = 'https://example.test/invite'
357
335
  jest.mocked(getAppConfig).mockReturnValue({
358
336
  displayName: 'Test App',
@@ -367,20 +345,6 @@ describe('SendSelectRecipient', () => {
367
345
  .mocked(getRecipientVerificationStatus)
368
346
  .mockReturnValue(RecipientVerificationStatus.UNVERIFIED)
369
347
 
370
- let resolveShare!: (value: {
371
- success: boolean
372
- dismissedAction: boolean
373
- message: string
374
- }) => void
375
- const sharePromise = new Promise<{
376
- success: boolean
377
- dismissedAction: boolean
378
- message: string
379
- }>((resolve) => {
380
- resolveShare = resolve
381
- })
382
- jest.mocked(Share.open).mockReturnValueOnce(sharePromise)
383
-
384
348
  const store = createMockStore(storeWithPhoneVerified)
385
349
 
386
350
  const { getByTestId } = render(
@@ -397,33 +361,19 @@ describe('SendSelectRecipient', () => {
397
361
  fireEvent.press(getByTestId('RecipientItem'))
398
362
  })
399
363
 
400
- const button = getByTestId('SendOrInviteButton')
401
- expect(button).toHaveTextContent('sendSelectRecipient.buttons.invite', { exact: false })
402
-
403
- await act(() => {
404
- fireEvent.press(button)
364
+ expect(navigate).toHaveBeenCalledWith(Screens.SendInvite, {
365
+ recipient: expect.objectContaining({
366
+ e164PhoneNumber: mockE164Number2Invite,
367
+ recipientType: RecipientType.PhoneNumber,
368
+ }),
369
+ shareUrl,
405
370
  })
406
371
 
407
- // Analytics fires synchronously on tap, before the share sheet resolves
408
- expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_invite_press, {
409
- recipientType: RecipientType.PhoneNumber,
410
- })
411
- expect(Share.open).toHaveBeenCalledWith(
412
- expect.objectContaining({
413
- message: expect.stringContaining(shareUrl),
414
- url: shareUrl,
415
- failOnCancel: false,
416
- })
417
- )
418
- expect(navigate).not.toHaveBeenCalled()
419
-
420
- await act(async () => {
421
- resolveShare({ success: true, dismissedAction: false, message: '' })
422
- await sharePromise
423
- })
372
+ // Search text is preserved so the user can return to the same picker state.
373
+ expect(searchInput.props.value).toBe(mockE164Number2Invite)
424
374
  })
425
375
 
426
- it('shows unknown address info text when searching for unknown address after making address verification request', async () => {
376
+ it('navigates and dispatches address verification when an unknown address is tapped and the user phone number is verified', async () => {
427
377
  jest
428
378
  .mocked(getRecipientVerificationStatus)
429
379
  .mockReturnValue(RecipientVerificationStatus.UNVERIFIED)
@@ -455,10 +405,12 @@ describe('SendSelectRecipient', () => {
455
405
  })
456
406
 
457
407
  expect(store.getActions()).toEqual([fetchAddressVerification(mockAccount2.toLowerCase())])
458
- expect(getByTestId('UnknownAddressInfo')).toBeTruthy()
459
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
408
+ expect(navigate).toHaveBeenCalledWith(
409
+ Screens.SendEnterAmount,
410
+ expect.objectContaining({ origin: SendOrigin.AppSendFlow })
411
+ )
460
412
  })
461
- it('shows unknown address info text and skips CPV request when searching for any address if PN not verified', async () => {
413
+ it('skips verification request for an address when the user phone number is not verified', async () => {
462
414
  const store = createMockStore(defaultStore)
463
415
 
464
416
  const { getByTestId } = render(
@@ -486,43 +438,11 @@ describe('SendSelectRecipient', () => {
486
438
  })
487
439
 
488
440
  expect(store.getActions()).toEqual([])
489
- expect(getByTestId('UnknownAddressInfo')).toBeTruthy()
490
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
491
- })
492
- 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 () => {
493
- jest
494
- .mocked(getRecipientVerificationStatus)
495
- .mockReturnValue(RecipientVerificationStatus.UNVERIFIED)
496
-
497
- const store = createMockStore(storeWithPhoneVerified)
498
-
499
- const { getByTestId } = render(
500
- <Provider store={store}>
501
- <SendSelectRecipient {...mockScreenProps({})} />
502
- </Provider>
441
+ // Navigation still proceeds — status is treated as UNVERIFIED locally.
442
+ expect(navigate).toHaveBeenCalledWith(
443
+ Screens.SendEnterAmount,
444
+ expect.objectContaining({ origin: SendOrigin.AppSendFlow })
503
445
  )
504
- await waitFor(() => {
505
- expect(getByTestId('SendSelectRecipientSearchInput')).toBeTruthy()
506
- })
507
- const searchInput = getByTestId('SendSelectRecipientSearchInput')
508
-
509
- await act(() => {
510
- fireEvent.changeText(searchInput, mockAccount)
511
- })
512
-
513
- expect(getByTestId('RecipientItem')).toHaveTextContent(mockRecipient.name, { exact: false })
514
- expect(getByTestId('RecipientItem')).toHaveTextContent(mockRecipient.displayNumber, {
515
- exact: false,
516
- })
517
-
518
- await act(() => {
519
- fireEvent.press(getByTestId('RecipientItem'))
520
- })
521
-
522
- expect(store.getActions()).toEqual([fetchAddressVerification(mockAccount)])
523
- expect(getByTestId('UnknownAddressInfo')).toBeTruthy()
524
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
525
- expect(getByTestId('SendOrInviteButton')).toHaveTextContent('send', { exact: false })
526
446
  })
527
447
  it('shows paste button if clipboard has address content', async () => {
528
448
  const store = createMockStore(defaultStore)
@@ -548,7 +468,7 @@ describe('SendSelectRecipient', () => {
548
468
  await expect(pasteButtonAfterPress).rejects.toThrow()
549
469
  })
550
470
 
551
- 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 () => {
552
472
  jest
553
473
  .mocked(getRecipientVerificationStatus)
554
474
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -577,11 +497,6 @@ describe('SendSelectRecipient', () => {
577
497
  fireEvent.press(getByTestId('RecipientItem'))
578
498
  })
579
499
 
580
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
581
-
582
- await act(() => {
583
- fireEvent.press(getByTestId('SendOrInviteButton'))
584
- })
585
500
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
586
501
  recipientType: RecipientType.PhoneNumber,
587
502
  })
@@ -599,7 +514,7 @@ describe('SendSelectRecipient', () => {
599
514
  isMiniPayRecipient: false,
600
515
  })
601
516
  })
602
- 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 () => {
603
518
  jest
604
519
  .mocked(getRecipientVerificationStatus)
605
520
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -628,9 +543,6 @@ describe('SendSelectRecipient', () => {
628
543
  await act(() => {
629
544
  fireEvent.press(getByTestId('RecipientItem'))
630
545
  })
631
- await act(() => {
632
- fireEvent.press(getByTestId('SendOrInviteButton'))
633
- })
634
546
 
635
547
  expect(navigate).toHaveBeenCalledWith(Screens.SendEnterAmount, {
636
548
  isFromScan: false,
@@ -646,7 +558,7 @@ describe('SendSelectRecipient', () => {
646
558
  isMiniPayRecipient: true,
647
559
  })
648
560
  })
649
- 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 () => {
650
562
  jest
651
563
  .mocked(getRecipientVerificationStatus)
652
564
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -681,11 +593,6 @@ describe('SendSelectRecipient', () => {
681
593
  fireEvent.press(getByTestId('RecipientItem'))
682
594
  })
683
595
 
684
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
685
-
686
- await act(() => {
687
- fireEvent.press(getByTestId('SendOrInviteButton'))
688
- })
689
596
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
690
597
  recipientType: RecipientType.PhoneNumber,
691
598
  })
@@ -696,7 +603,7 @@ describe('SendSelectRecipient', () => {
696
603
  origin: SendOrigin.AppSendFlow,
697
604
  })
698
605
  })
699
- 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 () => {
700
607
  jest
701
608
  .mocked(getRecipientVerificationStatus)
702
609
  .mockReturnValue(RecipientVerificationStatus.VERIFIED)
@@ -734,11 +641,6 @@ describe('SendSelectRecipient', () => {
734
641
  fireEvent.press(getByTestId('RecipientItem'))
735
642
  })
736
643
 
737
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
738
-
739
- await act(() => {
740
- fireEvent.press(getByTestId('SendOrInviteButton'))
741
- })
742
644
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
743
645
  recipientType: RecipientType.PhoneNumber,
744
646
  })
@@ -795,11 +697,6 @@ describe('SendSelectRecipient', () => {
795
697
  fireEvent.press(getByTestId('RecipientItem'))
796
698
  })
797
699
 
798
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
799
-
800
- await act(() => {
801
- fireEvent.press(getByTestId('SendOrInviteButton'))
802
- })
803
700
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
804
701
  recipientType: RecipientType.Address,
805
702
  })
@@ -861,11 +758,6 @@ describe('SendSelectRecipient', () => {
861
758
  fireEvent.press(getByTestId('RecipientItem'))
862
759
  })
863
760
 
864
- expect(getByTestId('SendOrInviteButton')).toBeTruthy()
865
-
866
- await act(() => {
867
- fireEvent.press(getByTestId('SendOrInviteButton'))
868
- })
869
761
  expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_send_press, {
870
762
  recipientType: RecipientType.Address,
871
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 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,40 +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')
330
- return
331
- }
332
-
333
- AppAnalytics.track(SendEvents.send_select_recipient_invite_press, {
334
- recipientType: recipient.recipientType,
335
- })
336
-
337
- try {
338
- await Share.open({
339
- message: t('inviteWithSmsMessage.shareMessage', { shareUrl }),
340
- url: shareUrl,
341
- failOnCancel: false,
342
- })
343
- } catch (error) {
344
- Logger.warn('SendSelectRecipient', 'Share sheet failed', error)
345
- }
346
-
347
- return
348
- }
349
-
350
- // Sends
351
- AppAnalytics.track(SendEvents.send_select_recipient_send_press, {
352
- recipientType: recipient.recipientType,
353
- })
354
- nextScreen(recipient)
355
- }
356
-
357
288
  const renderSearchResults = () => {
358
289
  if (mergedRecipients.length) {
359
290
  return (
@@ -362,7 +293,7 @@ function SendSelectRecipient({ route }: Props) {
362
293
  <RecipientPicker
363
294
  testID={'SelectRecipient/AllRecipientsPicker'}
364
295
  recipients={mergedRecipients}
365
- onSelectRecipient={setSelectedRecipientWrapper}
296
+ onSelectRecipient={setSelectedRecipient}
366
297
  selectedRecipient={recipient}
367
298
  isSelectedRecipientLoading={
368
299
  !!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
@@ -409,7 +340,7 @@ function SendSelectRecipient({ route }: Props) {
409
340
  <RecipientPicker
410
341
  testID={'SelectRecipient/ContactRecipientPicker'}
411
342
  recipients={contactRecipients}
412
- onSelectRecipient={setSelectedRecipientWrapper}
343
+ onSelectRecipient={setSelectedRecipient}
413
344
  selectedRecipient={recipient}
414
345
  isSelectedRecipientLoading={
415
346
  !!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
@@ -439,22 +370,6 @@ function SendSelectRecipient({ route }: Props) {
439
370
  </>
440
371
  )}
441
372
  </KeyboardAwareScrollView>
442
- {showUnknownAddressInfo && (
443
- <InLineNotification
444
- variant={NotificationVariant.Info}
445
- description={t('sendSelectRecipient.unknownAddressInfo')}
446
- testID="UnknownAddressInfo"
447
- style={styles.unknownAddressInfo}
448
- />
449
- )}
450
- {showSendOrInviteButton && (
451
- <SendOrInviteButton
452
- recipient={recipient}
453
- recipientVerificationStatus={recipientVerificationStatus}
454
- onPress={onPressSendOrInvite}
455
- shareUrl={shareUrl}
456
- />
457
- )}
458
373
  </SafeAreaView>
459
374
  )
460
375
  }
@@ -496,14 +411,6 @@ const styles = StyleSheet.create({
496
411
  padding: Spacing.Regular16,
497
412
  textAlign: 'center',
498
413
  },
499
- unknownAddressInfo: {
500
- margin: Spacing.Regular16,
501
- marginBottom: variables.contentPadding,
502
- },
503
- sendOrInviteButton: {
504
- margin: Spacing.Regular16,
505
- marginTop: variables.contentPadding,
506
- },
507
414
  })
508
415
 
509
416
  export default SendSelectRecipient