wallet-stack 1.0.0-alpha.131 → 1.0.0-alpha.133

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.
Files changed (59) hide show
  1. package/locales/base/translation.json +11 -0
  2. package/package.json +2 -3
  3. package/src/analytics/Events.tsx +3 -10
  4. package/src/analytics/Properties.tsx +9 -25
  5. package/src/analytics/docs.ts +11 -8
  6. package/src/app/ErrorMessages.ts +1 -7
  7. package/src/identity/actions.ts +1 -97
  8. package/src/identity/contactMapping.test.ts +3 -28
  9. package/src/identity/contactMapping.ts +2 -88
  10. package/src/identity/reducer.ts +0 -77
  11. package/src/identity/saga.ts +2 -85
  12. package/src/identity/selectors.ts +0 -2
  13. package/src/images/Images.ts +3 -0
  14. package/src/images/assets/invite-modal.png +0 -0
  15. package/src/images/assets/invite-modal@1.5x.png +0 -0
  16. package/src/images/assets/invite-modal@2x.png +0 -0
  17. package/src/images/assets/invite-modal@3x.png +0 -0
  18. package/src/images/assets/invite-modal@4x.png +0 -0
  19. package/src/images/assets/minipay.png +0 -0
  20. package/src/images/assets/minipay@1.5x.png +0 -0
  21. package/src/images/assets/minipay@2x.png +0 -0
  22. package/src/images/assets/minipay@3x.png +0 -0
  23. package/src/images/assets/minipay@4x.png +0 -0
  24. package/src/images/assets/valora.png +0 -0
  25. package/src/images/assets/valora@1.5x.png +0 -0
  26. package/src/images/assets/valora@2x.png +0 -0
  27. package/src/images/assets/valora@3x.png +0 -0
  28. package/src/images/assets/valora@4x.png +0 -0
  29. package/src/index.d.ts +0 -1
  30. package/src/navigator/Navigator.tsx +10 -14
  31. package/src/navigator/Screens.tsx +2 -2
  32. package/src/navigator/types.tsx +5 -6
  33. package/src/qrcode/utils.test.tsx +4 -96
  34. package/src/qrcode/utils.ts +5 -114
  35. package/src/redux/migrations.test.ts +13 -0
  36. package/src/redux/migrations.ts +4 -0
  37. package/src/redux/store.test.ts +1 -2
  38. package/src/redux/store.ts +1 -1
  39. package/src/send/SelectRecipientAddress.test.tsx +146 -0
  40. package/src/send/SelectRecipientAddress.tsx +166 -0
  41. package/src/send/SendConfirmation.test.tsx +28 -0
  42. package/src/send/SendConfirmation.tsx +18 -1
  43. package/src/send/SendInvite.test.tsx +107 -0
  44. package/src/send/SendInvite.tsx +99 -0
  45. package/src/send/SendSelectRecipient.test.tsx +44 -223
  46. package/src/send/SendSelectRecipient.tsx +41 -149
  47. package/src/send/actions.ts +0 -26
  48. package/src/send/saga.ts +1 -6
  49. package/src/components/AccountNumberCard.tsx +0 -23
  50. package/src/components/ErrorMessageInline.tsx +0 -78
  51. package/src/components/SingleDigitInput.tsx +0 -53
  52. package/src/icons/HamburgerCard.tsx +0 -55
  53. package/src/identity/saga.test.ts +0 -103
  54. package/src/identity/secureSend.ts +0 -171
  55. package/src/send/ValidateRecipientAccount.test.tsx +0 -182
  56. package/src/send/ValidateRecipientAccount.tsx +0 -392
  57. package/src/send/ValidateRecipientIntro.test.tsx +0 -61
  58. package/src/send/ValidateRecipientIntro.tsx +0 -136
  59. package/src/send/__snapshots__/ValidateRecipientAccount.test.tsx.snap +0 -777
@@ -68,6 +68,7 @@ import {
68
68
  v251Schema,
69
69
  v253Schema,
70
70
  v254Schema,
71
+ v255Schema,
71
72
  v28Schema,
72
73
  v2Schema,
73
74
  v35Schema,
@@ -1946,4 +1947,16 @@ describe('Redux persist migrations', () => {
1946
1947
  const migratedSchema = migrations[255](oldSchema)
1947
1948
  expect(migratedSchema.identity.addressToVerifiedBy).toStrictEqual({})
1948
1949
  })
1950
+
1951
+ it('works from 255 to 256', () => {
1952
+ const oldSchema = {
1953
+ ...v255Schema,
1954
+ identity: {
1955
+ ...v255Schema.identity,
1956
+ secureSendPhoneNumberMapping: { '+14155550000': {} },
1957
+ },
1958
+ }
1959
+ const migratedSchema = migrations[256](oldSchema)
1960
+ expect(migratedSchema.identity.secureSendPhoneNumberMapping).toBeUndefined()
1961
+ })
1949
1962
  })
@@ -2077,4 +2077,8 @@ export const migrations = {
2077
2077
  addressToVerifiedBy: {},
2078
2078
  },
2079
2079
  }),
2080
+ 256: (state: any) => ({
2081
+ ...state,
2082
+ identity: _.omit(state.identity, 'secureSendPhoneNumberMapping'),
2083
+ }),
2080
2084
  }
@@ -143,7 +143,7 @@ describe('store state', () => {
143
143
  {
144
144
  "_persist": {
145
145
  "rehydrated": true,
146
- "version": 255,
146
+ "version": 256,
147
147
  },
148
148
  "account": {
149
149
  "acceptedTerms": false,
@@ -254,7 +254,6 @@ describe('store state', () => {
254
254
  "total": 0,
255
255
  },
256
256
  "lastSavedContactsHash": null,
257
- "secureSendPhoneNumberMapping": {},
258
257
  "shouldRefreshStoredPasswordHash": true,
259
258
  },
260
259
  "imports": {
@@ -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: 255,
33
+ version: 256,
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],
@@ -0,0 +1,146 @@
1
+ import { fireEvent, render } from '@testing-library/react-native'
2
+ import * as React from 'react'
3
+ import { Provider } from 'react-redux'
4
+ import AppAnalytics from 'src/analytics/AppAnalytics'
5
+ import { SendEvents } from 'src/analytics/Events'
6
+ import { SendOrigin } from 'src/analytics/types'
7
+ import { navigate } from 'src/navigator/NavigationService'
8
+ import { Screens } from 'src/navigator/Screens'
9
+ import { RecipientType } from 'src/recipients/recipient'
10
+ import SelectRecipientAddress from 'src/send/SelectRecipientAddress'
11
+ import { createMockStore, getMockStackScreenProps } from 'test/utils'
12
+ import { mockAccount2, mockAccount3, mockE164Number3 } from 'test/values'
13
+
14
+ const mockRecipient = {
15
+ e164PhoneNumber: mockE164Number3,
16
+ displayNumber: '(415) 555-0123',
17
+ recipientType: RecipientType.PhoneNumber,
18
+ } as const
19
+
20
+ function renderScreen(storeOverrides: any) {
21
+ const store = createMockStore({
22
+ identity: {
23
+ e164NumberToAddress: {
24
+ [mockE164Number3]: [mockAccount2.toLowerCase(), mockAccount3.toLowerCase()],
25
+ },
26
+ addressToVerifiedBy: {
27
+ [mockAccount2.toLowerCase()]: 'valora',
28
+ [mockAccount3.toLowerCase()]: 'minipay',
29
+ },
30
+ ...storeOverrides?.identity,
31
+ },
32
+ })
33
+
34
+ const props = getMockStackScreenProps(Screens.SelectRecipientAddress, {
35
+ recipient: mockRecipient,
36
+ origin: SendOrigin.AppSendFlow,
37
+ })
38
+
39
+ return render(
40
+ <Provider store={store}>
41
+ <SelectRecipientAddress {...props} />
42
+ </Provider>
43
+ )
44
+ }
45
+
46
+ describe('SelectRecipientAddress', () => {
47
+ beforeEach(() => {
48
+ jest.clearAllMocks()
49
+ })
50
+
51
+ it('renders a row per verified address with its verifier label', () => {
52
+ const { getByText, getByTestId } = renderScreen({})
53
+
54
+ expect(getByTestId(`SelectRecipientAddress/Row/${mockAccount2.toLowerCase()}`)).toBeTruthy()
55
+ expect(getByTestId(`SelectRecipientAddress/Row/${mockAccount3.toLowerCase()}`)).toBeTruthy()
56
+ expect(getByText('Valora')).toBeTruthy()
57
+ expect(getByText('MiniPay')).toBeTruthy()
58
+ expect(
59
+ getByText(`selectRecipientAddress.explanation, {"name":"${mockRecipient.displayNumber}"}`)
60
+ ).toBeTruthy()
61
+ })
62
+
63
+ it('tracks the screen-open event with the number of verified addresses', () => {
64
+ renderScreen({})
65
+
66
+ expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.send_select_recipient_address_open, {
67
+ addressCount: 2,
68
+ })
69
+ })
70
+
71
+ it('filters out addresses without a verifier entry', () => {
72
+ const { queryByTestId } = renderScreen({
73
+ identity: {
74
+ e164NumberToAddress: {
75
+ [mockE164Number3]: [mockAccount2.toLowerCase(), mockAccount3.toLowerCase()],
76
+ },
77
+ addressToVerifiedBy: {
78
+ [mockAccount2.toLowerCase()]: 'valora',
79
+ // mockAccount3 intentionally omitted
80
+ },
81
+ },
82
+ })
83
+
84
+ expect(queryByTestId(`SelectRecipientAddress/Row/${mockAccount2.toLowerCase()}`)).toBeTruthy()
85
+ expect(queryByTestId(`SelectRecipientAddress/Row/${mockAccount3.toLowerCase()}`)).toBeNull()
86
+ })
87
+
88
+ it('filters out addresses with an unknown verifier', () => {
89
+ const { queryByTestId } = renderScreen({
90
+ identity: {
91
+ e164NumberToAddress: {
92
+ [mockE164Number3]: [mockAccount2.toLowerCase(), mockAccount3.toLowerCase()],
93
+ },
94
+ addressToVerifiedBy: {
95
+ [mockAccount2.toLowerCase()]: 'valora',
96
+ [mockAccount3.toLowerCase()]: 'somethingNew',
97
+ },
98
+ },
99
+ })
100
+
101
+ expect(queryByTestId(`SelectRecipientAddress/Row/${mockAccount2.toLowerCase()}`)).toBeTruthy()
102
+ expect(queryByTestId(`SelectRecipientAddress/Row/${mockAccount3.toLowerCase()}`)).toBeNull()
103
+ })
104
+
105
+ it('navigates to SendEnterAmount on Valora row tap with isMiniPayRecipient=false', () => {
106
+ const { getByTestId } = renderScreen({})
107
+ fireEvent.press(getByTestId(`SelectRecipientAddress/Row/${mockAccount2.toLowerCase()}`))
108
+
109
+ expect(navigate).toHaveBeenCalledWith(Screens.SendEnterAmount, {
110
+ isFromScan: false,
111
+ defaultTokenIdOverride: undefined,
112
+ forceTokenId: undefined,
113
+ recipient: {
114
+ ...mockRecipient,
115
+ address: mockAccount2.toLowerCase(),
116
+ },
117
+ origin: SendOrigin.AppSendFlow,
118
+ isMiniPayRecipient: false,
119
+ })
120
+ expect(AppAnalytics.track).toHaveBeenCalledWith(
121
+ SendEvents.send_select_recipient_address_select,
122
+ { verifier: 'valora' }
123
+ )
124
+ })
125
+
126
+ it('navigates to SendEnterAmount on MiniPay row tap with isMiniPayRecipient=true', () => {
127
+ const { getByTestId } = renderScreen({})
128
+ fireEvent.press(getByTestId(`SelectRecipientAddress/Row/${mockAccount3.toLowerCase()}`))
129
+
130
+ expect(navigate).toHaveBeenCalledWith(Screens.SendEnterAmount, {
131
+ isFromScan: false,
132
+ defaultTokenIdOverride: undefined,
133
+ forceTokenId: undefined,
134
+ recipient: {
135
+ ...mockRecipient,
136
+ address: mockAccount3.toLowerCase(),
137
+ },
138
+ origin: SendOrigin.AppSendFlow,
139
+ isMiniPayRecipient: true,
140
+ })
141
+ expect(AppAnalytics.track).toHaveBeenCalledWith(
142
+ SendEvents.send_select_recipient_address_select,
143
+ { verifier: 'minipay' }
144
+ )
145
+ })
146
+ })
@@ -0,0 +1,166 @@
1
+ import { NativeStackScreenProps } from '@react-navigation/native-stack'
2
+ import React, { useEffect } from 'react'
3
+ import { Trans, 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 { formatShortenedAddress } from 'src/account/utils'
7
+ import AppAnalytics from 'src/analytics/AppAnalytics'
8
+ import { SendEvents } from 'src/analytics/Events'
9
+ import BackButton from 'src/components/BackButton'
10
+ import Touchable from 'src/components/Touchable'
11
+ import CustomHeader from 'src/components/header/CustomHeader'
12
+ import { addressToVerifiedBySelector, e164NumberToAddressSelector } from 'src/identity/selectors'
13
+ import { miniPay, valora } from 'src/images/Images'
14
+ import { noHeader } from 'src/navigator/Headers'
15
+ import { navigate } from 'src/navigator/NavigationService'
16
+ import { Screens } from 'src/navigator/Screens'
17
+ import { StackParamList } from 'src/navigator/types'
18
+ import { getDisplayName } from 'src/recipients/recipient'
19
+ import { useSelector } from 'src/redux/hooks'
20
+ import Colors from 'src/styles/colors'
21
+ import { typeScale } from 'src/styles/fonts'
22
+ import { Spacing } from 'src/styles/styles'
23
+
24
+ type Props = NativeStackScreenProps<StackParamList, Screens.SelectRecipientAddress>
25
+
26
+ const VERIFIERS = {
27
+ valora: { name: 'Valora', icon: valora },
28
+ minipay: { name: 'MiniPay', icon: miniPay },
29
+ } as const
30
+
31
+ export type Verifier = keyof typeof VERIFIERS
32
+
33
+ const ICON_SIZE = 40
34
+
35
+ function isKnownVerifier(verifier: string | undefined): verifier is Verifier {
36
+ return !!verifier && Object.hasOwn(VERIFIERS, verifier)
37
+ }
38
+
39
+ function VerifierIcon({ verifier }: { verifier: Verifier }) {
40
+ return <Image source={VERIFIERS[verifier].icon} style={styles.icon} resizeMode="contain" />
41
+ }
42
+
43
+ function SelectRecipientAddress({ route }: Props) {
44
+ const { t } = useTranslation()
45
+ const { recipient, origin, forceTokenId, defaultTokenIdOverride } = route.params
46
+
47
+ const e164NumberToAddress = useSelector(e164NumberToAddressSelector)
48
+ const addressToVerifiedBy = useSelector(addressToVerifiedBySelector)
49
+
50
+ const addresses = e164NumberToAddress[recipient.e164PhoneNumber] || []
51
+ const verifiedEntries = addresses
52
+ .map((address) => ({ address, verifier: addressToVerifiedBy[address] }))
53
+ .filter((entry): entry is { address: string; verifier: Verifier } =>
54
+ isKnownVerifier(entry.verifier)
55
+ )
56
+
57
+ useEffect(() => {
58
+ AppAnalytics.track(SendEvents.send_select_recipient_address_open, {
59
+ addressCount: verifiedEntries.length,
60
+ })
61
+ }, [])
62
+
63
+ const onSelectAddress = (address: string, verifier: Verifier) => {
64
+ AppAnalytics.track(SendEvents.send_select_recipient_address_select, {
65
+ verifier,
66
+ })
67
+ navigate(Screens.SendEnterAmount, {
68
+ isFromScan: false,
69
+ defaultTokenIdOverride,
70
+ forceTokenId,
71
+ recipient: {
72
+ ...recipient,
73
+ address,
74
+ },
75
+ origin,
76
+ isMiniPayRecipient: verifier === 'minipay',
77
+ })
78
+ }
79
+
80
+ return (
81
+ <SafeAreaView style={styles.container} edges={['top']}>
82
+ <CustomHeader
83
+ style={styles.customHeader}
84
+ left={<BackButton eventName={SendEvents.send_select_recipient_address_back} />}
85
+ />
86
+ <ScrollView contentContainerStyle={styles.scrollContent}>
87
+ <Text style={styles.title}>{t('selectRecipientAddress.header')}</Text>
88
+ <Text style={styles.explanation}>
89
+ <Trans
90
+ i18nKey="selectRecipientAddress.explanation"
91
+ tOptions={{ name: getDisplayName(recipient, t) }}
92
+ >
93
+ <Text style={styles.explanationName} />
94
+ </Trans>
95
+ </Text>
96
+ {verifiedEntries.map(({ address, verifier }) => (
97
+ <Touchable
98
+ key={address}
99
+ onPress={() => onSelectAddress(address, verifier)}
100
+ testID={`SelectRecipientAddress/Row/${address}`}
101
+ >
102
+ <View style={styles.row}>
103
+ <VerifierIcon verifier={verifier} />
104
+ <View style={styles.rowContent}>
105
+ <Text style={styles.address}>{formatShortenedAddress(address)}</Text>
106
+ <Text style={styles.verifier}>{VERIFIERS[verifier].name}</Text>
107
+ </View>
108
+ </View>
109
+ </Touchable>
110
+ ))}
111
+ </ScrollView>
112
+ </SafeAreaView>
113
+ )
114
+ }
115
+
116
+ const styles = StyleSheet.create({
117
+ container: {
118
+ flex: 1,
119
+ },
120
+ customHeader: {
121
+ paddingHorizontal: Spacing.Thick24,
122
+ },
123
+ scrollContent: {
124
+ paddingBottom: Spacing.Smallest8,
125
+ },
126
+ title: {
127
+ ...typeScale.titleMedium,
128
+ paddingHorizontal: Spacing.Regular16,
129
+ paddingTop: Spacing.Thick24,
130
+ marginBottom: Spacing.Smallest8,
131
+ },
132
+ explanation: {
133
+ ...typeScale.bodySmall,
134
+ color: Colors.contentSecondary,
135
+ paddingHorizontal: Spacing.Regular16,
136
+ paddingBottom: Spacing.Regular16,
137
+ },
138
+ explanationName: {
139
+ fontWeight: 'bold',
140
+ },
141
+ icon: {
142
+ width: ICON_SIZE,
143
+ height: ICON_SIZE,
144
+ },
145
+ row: {
146
+ flexDirection: 'row',
147
+ alignItems: 'center',
148
+ paddingHorizontal: Spacing.Regular16,
149
+ paddingVertical: Spacing.Regular16,
150
+ },
151
+ rowContent: {
152
+ flex: 1,
153
+ marginLeft: Spacing.Small12,
154
+ },
155
+ address: {
156
+ ...typeScale.labelMedium,
157
+ },
158
+ verifier: {
159
+ ...typeScale.bodySmall,
160
+ color: Colors.contentSecondary,
161
+ },
162
+ })
163
+
164
+ SelectRecipientAddress.navigationOptions = noHeader
165
+
166
+ export default SelectRecipientAddress
@@ -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