diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2524f9558..7fabb373d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -35,6 +36,12 @@ + + + + + + diff --git a/app/app.tsx b/app/app.tsx index cf35c1d7e..2a36f82ad 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -45,6 +45,7 @@ import { NotificationsProvider } from "./components/notification" import { SafeAreaProvider } from "react-native-safe-area-context" import { FlashcardProvider } from "./contexts/Flashcard" import { NostrGroupChatProvider } from "./screens/chat/GroupChat/GroupChatProvider" +import { InviteDeepLinkHandler } from "./screens/invite-friend/InviteDeepLinkHandler" // FIXME should we only load the currently used local? // this would help to make the app load faster @@ -87,7 +88,9 @@ export const App = () => ( - + + + diff --git a/app/assets/illustrations/send-success.svg b/app/assets/illustrations/send-success.svg new file mode 100644 index 000000000..67572122b --- /dev/null +++ b/app/assets/illustrations/send-success.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/heart-symbol.png b/app/assets/images/heart-symbol.png new file mode 100644 index 000000000..089e9252b Binary files /dev/null and b/app/assets/images/heart-symbol.png differ diff --git a/app/components/contact-picker/contact-picker.tsx b/app/components/contact-picker/contact-picker.tsx new file mode 100644 index 000000000..bd6ee75bb --- /dev/null +++ b/app/components/contact-picker/contact-picker.tsx @@ -0,0 +1,445 @@ +import React, { useState, useEffect, useCallback } from "react" +import { + Modal, + View, + FlatList, + TouchableOpacity, + Alert, + Platform, + ActivityIndicator, + SafeAreaView, +} from "react-native" +import { SearchBar, ListItem, Text, makeStyles, useTheme } from "@rneui/themed" +import Contacts from "react-native-contacts" +import { + PERMISSIONS, + request, + check, + RESULTS, + openSettings, +} from "react-native-permissions" +import Icon from "react-native-vector-icons/Ionicons" +import { useI18nContext } from "@app/i18n/i18n-react" + +interface PhoneNumber { + label: string + number: string +} + +interface EmailAddress { + label: string + email: string +} + +interface Contact { + givenName: string + familyName: string + phoneNumbers: PhoneNumber[] + emailAddresses: EmailAddress[] + recordID: string +} + +interface ContactPickerProps { + visible: boolean + onClose: () => void + onSelectContact: (value: string, type: "phone" | "email") => void +} + +export const ContactPicker: React.FC = ({ + visible, + onClose, + onSelectContact, +}) => { + const styles = useStyles() + const { theme } = useTheme() + const { LL } = useI18nContext() + + const [contacts, setContacts] = useState([]) + const [filteredContacts, setFilteredContacts] = useState([]) + const [searchText, setSearchText] = useState("") + const [loading, setLoading] = useState(false) + const [permissionGranted, setPermissionGranted] = useState(false) + + // Request permission and load contacts when modal opens + useEffect(() => { + if (visible) { + checkAndRequestPermission() + } + }, [visible]) + + const checkAndRequestPermission = async () => { + try { + const permission = Platform.select({ + ios: PERMISSIONS.IOS.CONTACTS, + android: PERMISSIONS.ANDROID.READ_CONTACTS, + }) + + if (!permission) return + + const result = await check(permission) + + if (result === RESULTS.GRANTED) { + setPermissionGranted(true) + loadContacts() + } else if (result === RESULTS.BLOCKED) { + // Permission was previously denied and can't be requested + // On iOS, we can open settings + Alert.alert( + LL.common.permissionDenied?.() || "Permission Denied", + LL.common.contactsPermissionDeniedMessage?.() || + "Contacts permission has been denied. Please enable it in Settings.", + [ + { text: LL.common.cancel?.() || "Cancel", onPress: onClose, style: "cancel" }, + { + text: LL.common.openSettings?.() || "Open Settings", + onPress: () => { + openSettings().catch(() => { + console.warn("Cannot open settings") + }) + onClose() + }, + }, + ], + ) + } else { + // Request permission - this should show the native dialog + const requestResult = await request(permission) + + if (requestResult === RESULTS.GRANTED) { + setPermissionGranted(true) + loadContacts() + } else if (requestResult === RESULTS.BLOCKED) { + // User denied and selected "Don't ask again" or permission is blocked + Alert.alert( + LL.common.permissionDenied?.() || "Permission Denied", + LL.common.contactsPermissionDeniedMessage?.() || + "Contacts permission has been denied. Please enable it in Settings.", + [ + { + text: LL.common.cancel?.() || "Cancel", + onPress: onClose, + style: "cancel", + }, + { + text: LL.common.openSettings?.() || "Open Settings", + onPress: () => { + openSettings().catch(() => { + console.warn("Cannot open settings") + }) + onClose() + }, + }, + ], + ) + } else { + // User denied but can ask again later + onClose() + } + } + } catch (error) { + console.error("Error checking permissions:", error) + Alert.alert( + LL.common.error?.() || "Error", + "Failed to check permissions. Please try again.", + ) + } + } + + const loadContacts = async () => { + setLoading(true) + try { + Contacts.getAll() + .then((contactsList) => { + // Filter contacts with phone numbers or email addresses and sort alphabetically + const contactsWithContactInfo = contactsList + .filter( + (contact) => + (contact.phoneNumbers && contact.phoneNumbers.length > 0) || + (contact.emailAddresses && contact.emailAddresses.length > 0), + ) + .map((contact) => ({ + givenName: contact.givenName || "", + familyName: contact.familyName || "", + phoneNumbers: + contact.phoneNumbers?.map((phone) => ({ + label: phone.label || "mobile", + number: phone.number, + })) || [], + emailAddresses: + contact.emailAddresses?.map((email) => ({ + label: email.label || "personal", + email: email.email, + })) || [], + recordID: contact.recordID, + })) + .sort((a, b) => { + const nameA = `${a.givenName} ${a.familyName}`.toLowerCase() + const nameB = `${b.givenName} ${b.familyName}`.toLowerCase() + return nameA.localeCompare(nameB) + }) + + setContacts(contactsWithContactInfo) + setFilteredContacts(contactsWithContactInfo) + }) + .catch((error) => { + console.error("Error loading contacts:", error) + Alert.alert( + LL.common.error?.() || "Error", + LL.common.errorLoadingContacts?.() || + "Failed to load contacts. Please try again.", + ) + }) + } finally { + setLoading(false) + } + } + + const handleSearch = useCallback( + (text: string) => { + setSearchText(text) + if (text.trim() === "") { + setFilteredContacts(contacts) + } else { + const filtered = contacts.filter((contact) => { + const fullName = `${contact.givenName} ${contact.familyName}`.toLowerCase() + const searchLower = text.toLowerCase() + return ( + fullName.includes(searchLower) || + contact.phoneNumbers.some((phone) => + phone.number.replace(/\D/g, "").includes(text.replace(/\D/g, "")), + ) || + contact.emailAddresses.some((email) => + email.email.toLowerCase().includes(searchLower), + ) + ) + }) + setFilteredContacts(filtered) + } + }, + [contacts], + ) + + const handleSelectContact = (contact: Contact) => { + const totalPhones = contact.phoneNumbers.length + const totalEmails = contact.emailAddresses.length + const totalOptions = totalPhones + totalEmails + + // Case a: Only one phone number and no email + if (totalPhones === 1 && totalEmails === 0) { + onSelectContact(contact.phoneNumbers[0].number, "phone") + onClose() + return + } + + // Case e: No phone numbers and only one email + if (totalPhones === 0 && totalEmails === 1) { + onSelectContact(contact.emailAddresses[0].email, "email") + onClose() + return + } + + // For all other cases, show selection dialog + const options = [] + + // Add phone numbers to options + contact.phoneNumbers.forEach((phone) => { + options.push({ + text: `📱 ${phone.label}: ${phone.number}`, + onPress: () => { + onSelectContact(phone.number, "phone") + onClose() + }, + }) + }) + + // Add email addresses to options + contact.emailAddresses.forEach((email) => { + options.push({ + text: `✉️ ${email.label}: ${email.email}`, + onPress: () => { + onSelectContact(email.email, "email") + onClose() + }, + }) + }) + + // Add cancel button + options.push({ text: LL.common.cancel?.() || "Cancel", style: "cancel" }) + + // Show the selection dialog + Alert.alert( + LL.common.selectContactMethod?.() || "Select Contact Method", + undefined, + options, + ) + } + + const renderContact = ({ item }: { item: Contact }) => { + const fullName = `${item.givenName} ${item.familyName}`.trim() + + // Build display text for contact methods + let contactDisplay = "" + const totalContacts = item.phoneNumbers.length + item.emailAddresses.length + + if (totalContacts === 1) { + // Show the single contact method + if (item.phoneNumbers.length === 1) { + contactDisplay = item.phoneNumbers[0].number + } else if (item.emailAddresses.length === 1) { + contactDisplay = item.emailAddresses[0].email + } + } else { + // Show count of contact methods + const parts = [] + if (item.phoneNumbers.length > 0) { + parts.push( + `${item.phoneNumbers.length} phone${item.phoneNumbers.length > 1 ? "s" : ""}`, + ) + } + if (item.emailAddresses.length > 0) { + parts.push( + `${item.emailAddresses.length} email${ + item.emailAddresses.length > 1 ? "s" : "" + }`, + ) + } + contactDisplay = parts.join(", ") + } + + return ( + handleSelectContact(item)} + bottomDivider + containerStyle={styles.contactItem} + > + + {fullName} + + {contactDisplay} + + + + + ) + } + + return ( + + + + + + + + {LL.common.selectContact?.() || "Select Contact"} + + + + + + + {loading ? ( + + + + ) : ( + item.recordID} + contentContainerStyle={styles.listContainer} + ListEmptyComponent={ + + + {searchText + ? LL.common.noContactsFound?.() || "No contacts found" + : LL.common.noContactsWithPhone?.() || + "No contacts with phone numbers"} + + + } + /> + )} + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + closeButton: { + width: 40, + height: 40, + justifyContent: "center", + alignItems: "center", + }, + title: { + flex: 1, + textAlign: "center", + }, + searchContainer: { + backgroundColor: "transparent", + borderTopWidth: 0, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + searchInputContainer: { + backgroundColor: colors.searchBg || colors.grey5, + }, + listContainer: { + flexGrow: 1, + }, + contactItem: { + backgroundColor: colors.background, + }, + contactName: { + fontSize: 16, + fontWeight: "500", + color: colors.black, + }, + contactPhone: { + fontSize: 14, + color: colors.grey3, + marginTop: 4, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingVertical: 50, + }, + emptyText: { + fontSize: 16, + color: colors.grey3, + textAlign: "center", + }, +})) diff --git a/app/components/contact-picker/index.ts b/app/components/contact-picker/index.ts new file mode 100644 index 000000000..9da42e7f4 --- /dev/null +++ b/app/components/contact-picker/index.ts @@ -0,0 +1 @@ +export * from "./contact-picker" diff --git a/app/components/home-screen/QuickStart.tsx b/app/components/home-screen/QuickStart.tsx index 129de243d..5d7771fd9 100644 --- a/app/components/home-screen/QuickStart.tsx +++ b/app/components/home-screen/QuickStart.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react" -import { Dimensions, Linking, TouchableOpacity, View } from "react-native" +import { Dimensions, Linking, TouchableOpacity, View, Image } from "react-native" import { StackNavigationProp } from "@react-navigation/stack" import Carousel from "react-native-reanimated-carousel" import { Icon, makeStyles, Text, useTheme } from "@rneui/themed" @@ -13,6 +13,7 @@ import Flashcard from "@app/assets/icons/empty-flashcard.svg" import NonCustodialWallet from "@app/assets/illustrations/non-custodial-wallet.svg" import GoldWallet from "@app/assets/illustrations/gold-wallet.svg" import SecureWallet from "@app/assets/illustrations/secure-wallet.svg" +import FriendsIcon from "@app/assets/images/heart-symbol.png" // components import { UpgradeAccountModal } from "../upgrade-account-modal" @@ -64,6 +65,13 @@ const QuickStart = () => { } let carouselData = [ + { + type: "invite", + title: LL.HomeScreen.inviteTitle?.() || "Invite Friends", + description: LL.HomeScreen.inviteDesc?.() || "Get for inviting friends to Flash", + image: FriendsIcon, + onPress: () => navigation.navigate("InviteFriend"), + }, { type: "upgrade", title: LL.HomeScreen.upgradeTitle(), @@ -120,6 +128,9 @@ const QuickStart = () => { }, ] + if (persistentState?.closedQuickStartTypes?.includes("invite")) { + carouselData = carouselData.filter((el) => el.type !== "invite") + } if ( data?.me?.defaultAccount.level !== AccountLevel.Zero || persistentState?.closedQuickStartTypes?.includes("upgrade") @@ -177,10 +188,20 @@ const QuickStart = () => { } const renderItem = ({ item, index }: RenderItemProps) => { - const Image = item.image + const ImageOrAsset = item.image + const isAsset = typeof ImageOrAsset === "number" + const isHeartIcon = item.type === "invite" && isAsset + return ( - + {isAsset ? ( + + ) : ( + + )} {item.title} @@ -245,6 +266,14 @@ const useStyles = makeStyles(({ colors }) => ({ right: 0, padding: 5, }, + imageStyle: { + width: width / 3, + height: width / 3, + resizeMode: "contain", + }, + heartIconRotation: { + transform: [{ rotate: "-25deg" }], + }, })) export default QuickStart diff --git a/app/components/input/EmailInput.tsx b/app/components/input/EmailInput.tsx new file mode 100644 index 000000000..1d9929e08 --- /dev/null +++ b/app/components/input/EmailInput.tsx @@ -0,0 +1,58 @@ +import React from "react" +import { View } from "react-native" +import { Input, makeStyles, Text } from "@rneui/themed" + +type Props = { + title: string + email?: string + setEmail: (val: string) => void +} + +const EmailInput: React.FC = ({ title, email, setEmail }) => { + const styles = useStyles() + + return ( + + + {title} + + + + + + ) +} + +export default EmailInput + +const useStyles = makeStyles(({ colors }) => ({ + wrapper: {}, + header: { + marginBottom: 5, + }, + inputContainer: { + flexDirection: "row", + marginBottom: 10, + }, + inputComponentContainerStyle: { + flex: 1, + paddingLeft: 0, + paddingRight: 0, + }, + inputContainerStyle: { + borderWidth: 1, + borderRadius: 10, + borderColor: colors.border02, + paddingHorizontal: 10, + }, +})) diff --git a/app/components/input/PhoneNumberInput.tsx b/app/components/input/PhoneNumberInput.tsx new file mode 100644 index 000000000..7ee0dc00f --- /dev/null +++ b/app/components/input/PhoneNumberInput.tsx @@ -0,0 +1,138 @@ +import React, { useMemo } from "react" +import { TouchableOpacity, View } from "react-native" +import { Input, makeStyles, Text, useTheme } from "@rneui/themed" +import { + CountryCode as PhoneNumberCountryCode, + getCountryCallingCode, +} from "libphonenumber-js/mobile" +import CountryPicker, { + DARK_THEME, + DEFAULT_THEME, + Flag, + CountryCode, +} from "react-native-country-picker-modal" +import { PhoneCodeChannelType, useSupportedCountriesQuery } from "@app/graphql/generated" + +const DEFAULT_COUNTRY_CODE = "JM" +const PLACEHOLDER_PHONE_NUMBER = "876-555-7890" + +type Props = { + title: string + countryCode?: CountryCode + phoneNumber?: string + setCountryCode: (countryCode: CountryCode) => void + setPhoneNumber: (val: string) => void +} + +const PhoneNumberInput: React.FC = ({ + title, + countryCode, + phoneNumber, + setCountryCode, + setPhoneNumber, +}) => { + const styles = useStyles() + const { mode } = useTheme().theme + + const { data, loading } = useSupportedCountriesQuery() + const { isWhatsAppSupported, isSmsSupported, supportedCountries } = useMemo(() => { + const currentCountry = data?.globals?.supportedCountries.find( + (country) => country.id === countryCode, + ) + + const supportedCountries = (data?.globals?.supportedCountries.map( + (country) => country.id, + ) || []) as CountryCode[] + + const isWhatsAppSupported = + currentCountry?.supportedAuthChannels.includes(PhoneCodeChannelType.Whatsapp) || + false + const isSmsSupported = + currentCountry?.supportedAuthChannels.includes(PhoneCodeChannelType.Sms) || false + + return { + isWhatsAppSupported, + isSmsSupported, + supportedCountries, + } + }, [data?.globals, countryCode]) + + return ( + + + {title} + + + setCountryCode(country.cca2)} + renderFlagButton={({ countryCode, onOpen }) => + countryCode && ( + + + + +{getCountryCallingCode(countryCode as PhoneNumberCountryCode)} + + + ) + } + withCallingCodeButton={true} + withFilter={true} + withCallingCode={true} + filterProps={{ + autoFocus: true, + }} + /> + + + + ) +} + +export default PhoneNumberInput + +const useStyles = makeStyles(({ colors }) => ({ + wrapper: { + marginBottom: 10, + }, + header: { + marginBottom: 5, + }, + inputContainer: { + flexDirection: "row", + marginBottom: 10, + }, + countryPickerButtonStyle: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderRadius: 10, + borderColor: colors.border02, + paddingHorizontal: 10, + }, + inputComponentContainerStyle: { + flex: 1, + marginLeft: 10, + paddingLeft: 0, + paddingRight: 0, + }, + inputContainerStyle: { + borderWidth: 1, + borderRadius: 10, + borderColor: colors.border02, + paddingHorizontal: 10, + }, +})) diff --git a/app/components/input/index.ts b/app/components/input/index.ts new file mode 100644 index 000000000..b76af91ba --- /dev/null +++ b/app/components/input/index.ts @@ -0,0 +1,4 @@ +import PhoneNumberInput from "./PhoneNumberInput" +import EmailInput from "./EmailInput" + +export { PhoneNumberInput, EmailInput } diff --git a/app/graphql/front-end-mutations.ts b/app/graphql/front-end-mutations.ts index 9f3cf05ad..16aa270be 100644 --- a/app/graphql/front-end-mutations.ts +++ b/app/graphql/front-end-mutations.ts @@ -195,3 +195,28 @@ gql` } } ` + +gql` + mutation createInvite($input: CreateInviteInput!) { + createInvite(input: $input) { + invite { + id + contact + method + status + createdAt + expiresAt + } + errors + } + } +` + +gql` + mutation redeemInvite($input: RedeemInviteInput!) { + redeemInvite(input: $input) { + success + errors + } + } +` diff --git a/app/graphql/front-end-queries.ts b/app/graphql/front-end-queries.ts index 822e492fe..c9886016c 100644 --- a/app/graphql/front-end-queries.ts +++ b/app/graphql/front-end-queries.ts @@ -282,10 +282,14 @@ gql` denominatorCurrency } } - query npubByUsername($username: Username!) { - npubByUsername(username: $username) { - npub - username + + query invitePreview($token: String!) { + invitePreview(token: $token) { + contact + method + isValid + inviterUsername + expiresAt } } ` diff --git a/app/graphql/generated.gql b/app/graphql/generated.gql index 242a55b4a..831dac1e0 100644 --- a/app/graphql/generated.gql +++ b/app/graphql/generated.gql @@ -264,6 +264,22 @@ mutation captchaRequestAuthCode($input: CaptchaRequestAuthCodeInput!) { } } +mutation createInvite($input: CreateInviteInput!) { + createInvite(input: $input) { + invite { + id + contact + method + status + createdAt + expiresAt + __typename + } + errors + __typename + } +} + mutation deviceNotificationTokenCreate($input: DeviceNotificationTokenCreateInput!) { deviceNotificationTokenCreate(input: $input) { errors { @@ -517,6 +533,14 @@ mutation quizCompleted($input: QuizCompletedInput!) { } } +mutation redeemInvite($input: RedeemInviteInput!) { + redeemInvite(input: $input) { + success + errors + __typename + } +} + mutation userContactUpdateAlias($input: UserContactUpdateAliasInput!) { userContactUpdateAlias(input: $input) { errors { @@ -1101,6 +1125,17 @@ query homeUnauthed { } } +query invitePreview($token: String!) { + invitePreview(token: $token) { + contact + method + isValid + inviterUsername + expiresAt + __typename + } +} + query language { me { id diff --git a/app/graphql/generated.ts b/app/graphql/generated.ts index 53407af7b..70af99f00 100644 --- a/app/graphql/generated.ts +++ b/app/graphql/generated.ts @@ -36,7 +36,7 @@ export type Scalars = { EndpointUrl: { input: string; output: string; } /** Feedback shared with our user */ Feedback: { input: string; output: string; } - /** (Positive) Cent amount (1/100 of a dollar) as a float */ + /** Cent amount (1/100 of a dollar) as a float, can be positive or negative */ FractionalCentAmount: { input: number; output: number; } /** Hex-encoded string of 32 bytes */ Hex32Bytes: { input: string; output: string; } @@ -210,7 +210,7 @@ export type BtcWallet = Wallet & { readonly __typename: 'BTCWallet'; readonly accountId: Scalars['ID']['output']; /** A balance stored in BTC. */ - readonly balance: Scalars['SignedAmount']['output']; + readonly balance: Scalars['FractionalCentAmount']['output']; readonly id: Scalars['ID']['output']; readonly lnurlp?: Maybe; /** An unconfirmed incoming onchain balance. */ @@ -369,6 +369,17 @@ export type Country = { readonly supportedAuthChannels: ReadonlyArray; }; +export type CreateInviteInput = { + readonly contact: Scalars['String']['input']; + readonly method: InviteMethod; +}; + +export type CreateInvitePayload = { + readonly __typename: 'CreateInvitePayload'; + readonly errors: ReadonlyArray; + readonly invite?: Maybe; +}; + export type Currency = { readonly __typename: 'Currency'; readonly flag: Scalars['String']['output']; @@ -504,6 +515,40 @@ export type IntraLedgerUsdPaymentSendInput = { readonly walletId: Scalars['WalletId']['input']; }; +export type Invite = { + readonly __typename: 'Invite'; + readonly contact: Scalars['String']['output']; + readonly createdAt: Scalars['String']['output']; + readonly expiresAt: Scalars['String']['output']; + readonly id: Scalars['ID']['output']; + readonly method: InviteMethod; + readonly status: InviteStatus; +}; + +export const InviteMethod = { + Email: 'EMAIL', + Sms: 'SMS', + Whatsapp: 'WHATSAPP' +} as const; + +export type InviteMethod = typeof InviteMethod[keyof typeof InviteMethod]; +export type InvitePreview = { + readonly __typename: 'InvitePreview'; + readonly contact: Scalars['String']['output']; + readonly expiresAt: Scalars['String']['output']; + readonly inviterUsername?: Maybe; + readonly isValid: Scalars['Boolean']['output']; + readonly method: Scalars['String']['output']; +}; + +export const InviteStatus = { + Accepted: 'ACCEPTED', + Expired: 'EXPIRED', + Pending: 'PENDING', + Sent: 'SENT' +} as const; + +export type InviteStatus = typeof InviteStatus[keyof typeof InviteStatus]; export const InvoicePaymentStatus = { Expired: 'EXPIRED', Paid: 'PAID', @@ -740,6 +785,7 @@ export type Mutation = { readonly callbackEndpointDelete: SuccessPayload; readonly captchaCreateChallenge: CaptchaCreateChallengePayload; readonly captchaRequestAuthCode: SuccessPayload; + readonly createInvite: CreateInvitePayload; readonly deviceNotificationTokenCreate: SuccessPayload; readonly feedbackSubmit: SuccessPayload; /** @@ -829,6 +875,7 @@ export type Mutation = { readonly onChainUsdPaymentSend: PaymentSendPayload; readonly onChainUsdPaymentSendAsBtcDenominated: PaymentSendPayload; readonly quizCompleted: QuizCompletedPayload; + readonly redeemInvite: RedeemInvitePayload; /** * Returns an offer from Flash for a user to withdraw from their USD wallet (denominated in cents). * The user can review this offer and then execute the withdrawal by calling the initiateCashout mutation. @@ -902,6 +949,11 @@ export type MutationCaptchaRequestAuthCodeArgs = { }; +export type MutationCreateInviteArgs = { + input: CreateInviteInput; +}; + + export type MutationDeviceNotificationTokenCreateArgs = { input: DeviceNotificationTokenCreateInput; }; @@ -1032,6 +1084,11 @@ export type MutationQuizCompletedArgs = { }; +export type MutationRedeemInviteArgs = { + input: RedeemInviteInput; +}; + + export type MutationRequestCashoutArgs = { input: RequestCashoutInput; }; @@ -1353,6 +1410,7 @@ export type Query = { readonly hasPromptedSetDefaultAccount: Scalars['Boolean']['output']; readonly hiddenBalanceToolTip: Scalars['Boolean']['output']; readonly hideBalance: Scalars['Boolean']['output']; + readonly invitePreview?: Maybe; readonly isFlashNpub?: Maybe; readonly lnInvoicePaymentStatus: LnInvoicePaymentStatusPayload; readonly me?: Maybe; @@ -1389,6 +1447,11 @@ export type QueryBtcPriceListArgs = { }; +export type QueryInvitePreviewArgs = { + token: Scalars['String']['input']; +}; + + export type QueryIsFlashNpubArgs = { input: IsFlashNpubInput; }; @@ -1492,6 +1555,16 @@ export type RealtimePricePayload = { readonly realtimePrice?: Maybe; }; +export type RedeemInviteInput = { + readonly token: Scalars['String']['input']; +}; + +export type RedeemInvitePayload = { + readonly __typename: 'RedeemInvitePayload'; + readonly errors: ReadonlyArray; + readonly success: Scalars['Boolean']['output']; +}; + export type RequestCashoutInput = { /** Amount in USD cents. */ readonly amount: Scalars['USDCents']['input']; @@ -1704,7 +1777,7 @@ export type UpgradePayload = { export type UsdWallet = Wallet & { readonly __typename: 'UsdWallet'; readonly accountId: Scalars['ID']['output']; - readonly balance: Scalars['SignedAmount']['output']; + readonly balance: Scalars['FractionalCentAmount']['output']; readonly id: Scalars['ID']['output']; readonly lnurlp?: Maybe; /** An unconfirmed incoming onchain balance. */ @@ -1962,7 +2035,7 @@ export type UserUpdateUsernamePayload = { /** A generic wallet which stores value in one of our supported currencies. */ export type Wallet = { readonly accountId: Scalars['ID']['output']; - readonly balance: Scalars['SignedAmount']['output']; + readonly balance: Scalars['FractionalCentAmount']['output']; readonly id: Scalars['ID']['output']; readonly lnurlp?: Maybe; readonly pendingIncomingBalance: Scalars['SignedAmount']['output']; @@ -2178,6 +2251,20 @@ export type UserUpdateNpubMutationVariables = Exact<{ export type UserUpdateNpubMutation = { readonly __typename: 'Mutation', readonly userUpdateNpub: { readonly __typename: 'UserUpdateNpubPayload', readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly code?: string | null }>, readonly user?: { readonly __typename: 'User', readonly id: string, readonly npub?: string | null } | null } }; +export type CreateInviteMutationVariables = Exact<{ + input: CreateInviteInput; +}>; + + +export type CreateInviteMutation = { readonly __typename: 'Mutation', readonly createInvite: { readonly __typename: 'CreateInvitePayload', readonly errors: ReadonlyArray, readonly invite?: { readonly __typename: 'Invite', readonly id: string, readonly contact: string, readonly method: InviteMethod, readonly status: InviteStatus, readonly createdAt: string, readonly expiresAt: string } | null } }; + +export type RedeemInviteMutationVariables = Exact<{ + input: RedeemInviteInput; +}>; + + +export type RedeemInviteMutation = { readonly __typename: 'Mutation', readonly redeemInvite: { readonly __typename: 'RedeemInvitePayload', readonly success: boolean, readonly errors: ReadonlyArray } }; + export type AuthQueryVariables = Exact<{ [key: string]: never; }>; @@ -2279,12 +2366,12 @@ export type RealtimePriceUnauthedQueryVariables = Exact<{ export type RealtimePriceUnauthedQuery = { readonly __typename: 'Query', readonly realtimePrice: { readonly __typename: 'RealtimePrice', readonly timestamp: number, readonly denominatorCurrency: string, readonly btcSatPrice: { readonly __typename: 'PriceOfOneSatInMinorUnit', readonly base: number, readonly offset: number }, readonly usdCentPrice: { readonly __typename: 'PriceOfOneUsdCentInMinorUnit', readonly base: number, readonly offset: number } } }; -export type NpubByUsernameQueryVariables = Exact<{ - username: Scalars['Username']['input']; +export type InvitePreviewQueryVariables = Exact<{ + token: Scalars['String']['input']; }>; -export type NpubByUsernameQuery = { readonly __typename: 'Query', readonly npubByUsername?: { readonly __typename: 'npubByUsername', readonly npub?: string | null, readonly username?: string | null } | null }; +export type InvitePreviewQuery = { readonly __typename: 'Query', readonly invitePreview?: { readonly __typename: 'InvitePreview', readonly contact: string, readonly method: string, readonly isValid: boolean, readonly inviterUsername?: string | null, readonly expiresAt: string } | null }; export type RealtimePriceWsSubscriptionVariables = Exact<{ currency: Scalars['DisplayCurrency']['input']; @@ -3697,6 +3784,81 @@ export function useUserUpdateNpubMutation(baseOptions?: Apollo.MutationHookOptio export type UserUpdateNpubMutationHookResult = ReturnType; export type UserUpdateNpubMutationResult = Apollo.MutationResult; export type UserUpdateNpubMutationOptions = Apollo.BaseMutationOptions; +export const CreateInviteDocument = gql` + mutation createInvite($input: CreateInviteInput!) { + createInvite(input: $input) { + invite { + id + contact + method + status + createdAt + expiresAt + } + errors + } +} + `; +export type CreateInviteMutationFn = Apollo.MutationFunction; + +/** + * __useCreateInviteMutation__ + * + * To run a mutation, you first call `useCreateInviteMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateInviteMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createInviteMutation, { data, loading, error }] = useCreateInviteMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateInviteMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateInviteDocument, options); + } +export type CreateInviteMutationHookResult = ReturnType; +export type CreateInviteMutationResult = Apollo.MutationResult; +export type CreateInviteMutationOptions = Apollo.BaseMutationOptions; +export const RedeemInviteDocument = gql` + mutation redeemInvite($input: RedeemInviteInput!) { + redeemInvite(input: $input) { + success + errors + } +} + `; +export type RedeemInviteMutationFn = Apollo.MutationFunction; + +/** + * __useRedeemInviteMutation__ + * + * To run a mutation, you first call `useRedeemInviteMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRedeemInviteMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [redeemInviteMutation, { data, loading, error }] = useRedeemInviteMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useRedeemInviteMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RedeemInviteDocument, options); + } +export type RedeemInviteMutationHookResult = ReturnType; +export type RedeemInviteMutationResult = Apollo.MutationResult; +export type RedeemInviteMutationOptions = Apollo.BaseMutationOptions; export const AuthDocument = gql` query auth { me { @@ -4477,42 +4639,45 @@ export function useRealtimePriceUnauthedLazyQuery(baseOptions?: Apollo.LazyQuery export type RealtimePriceUnauthedQueryHookResult = ReturnType; export type RealtimePriceUnauthedLazyQueryHookResult = ReturnType; export type RealtimePriceUnauthedQueryResult = Apollo.QueryResult; -export const NpubByUsernameDocument = gql` - query npubByUsername($username: Username!) { - npubByUsername(username: $username) { - npub - username +export const InvitePreviewDocument = gql` + query invitePreview($token: String!) { + invitePreview(token: $token) { + contact + method + isValid + inviterUsername + expiresAt } } `; /** - * __useNpubByUsernameQuery__ + * __useInvitePreviewQuery__ * - * To run a query within a React component, call `useNpubByUsernameQuery` and pass it any options that fit your needs. - * When your component renders, `useNpubByUsernameQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useInvitePreviewQuery` and pass it any options that fit your needs. + * When your component renders, `useInvitePreviewQuery` returns an object from Apollo Client that contains loading, error, and data properties * you can use to render your UI. * * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * * @example - * const { data, loading, error } = useNpubByUsernameQuery({ + * const { data, loading, error } = useInvitePreviewQuery({ * variables: { - * username: // value for 'username' + * token: // value for 'token' * }, * }); */ -export function useNpubByUsernameQuery(baseOptions: Apollo.QueryHookOptions) { +export function useInvitePreviewQuery(baseOptions: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(NpubByUsernameDocument, options); + return Apollo.useQuery(InvitePreviewDocument, options); } -export function useNpubByUsernameLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useInvitePreviewLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(NpubByUsernameDocument, options); + return Apollo.useLazyQuery(InvitePreviewDocument, options); } -export type NpubByUsernameQueryHookResult = ReturnType; -export type NpubByUsernameLazyQueryHookResult = ReturnType; -export type NpubByUsernameQueryResult = Apollo.QueryResult; +export type InvitePreviewQueryHookResult = ReturnType; +export type InvitePreviewLazyQueryHookResult = ReturnType; +export type InvitePreviewQueryResult = Apollo.QueryResult; export const RealtimePriceWsDocument = gql` subscription realtimePriceWs($currency: DisplayCurrency!) { realtimePrice(input: {currency: $currency}) { diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index 8ef6c6245..c01764efd 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -552,6 +552,8 @@ const en: BaseTranslation = { addFlashcard: "Add Flashcard", upgradeTitle: "Add your phone number", upgradeDesc: "Backup your cash wallet and increase transaction limits.", + inviteTitle: "Invite Friends", + inviteDesc: "Get rewards for inviting friends to Flash", currencyTitle:"Change to your local currency", currencyDesc: "Review our available currency list and select your currency.", flashcardTitle: "Get a Flashcard", @@ -837,7 +839,19 @@ const en: BaseTranslation = { advanceMode: "Enable Bitcoin Account (Advanced Mode)", keysManagement: "Key management", showBtcAccount: "Show Bitcoin account", - hideBtcAccount: "Hide Bitcoin account" + hideBtcAccount: "Hide Bitcoin account", + inviteFriend: "Invite a friend" + }, + InviteFriend: { + invitation: "Invitation", + title: "Invite a friend to Flash!", + subtitle: "Enter a phone number or email address to invite a friend. By inviting a friend, you confirm that the recipient has given consent to receive this invitation.", + phoneNumber: "Enter phone number", + email: "Enter email address", + invite: "Invite", + or: "OR", + invitationSuccessTitle: "Invitation has been sent to {value: string}", + done: "Done" }, NotificationSettingsScreen: { title: "Notification Settings", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index 3aed41fed..c6365e5b4 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -1728,6 +1728,14 @@ type RootTranslation = { * B​a​c​k​u​p​ ​y​o​u​r​ ​c​a​s​h​ ​w​a​l​l​e​t​ ​a​n​d​ ​i​n​c​r​e​a​s​e​ ​t​r​a​n​s​a​c​t​i​o​n​ ​l​i​m​i​t​s​. */ upgradeDesc: string + /** + * I​n​v​i​t​e​ ​F​r​i​e​n​d​s + */ + inviteTitle: string + /** + * G​e​t​ ​r​e​w​a​r​d​s​ ​f​o​r​ ​i​n​v​i​t​i​n​g​ ​f​r​i​e​n​d​s​ ​t​o​ ​F​l​a​s​h + */ + inviteDesc: string /** * C​h​a​n​g​e​ ​t​o​ ​y​o​u​r​ ​l​o​c​a​l​ ​c​u​r​r​e​n​c​y */ @@ -2729,6 +2737,49 @@ type RootTranslation = { * H​i​d​e​ ​B​i​t​c​o​i​n​ ​a​c​c​o​u​n​t */ hideBtcAccount: string + /** + * I​n​v​i​t​e​ ​a​ ​f​r​i​e​n​d + */ + inviteFriend: string + } + InviteFriend: { + /** + * I​n​v​i​t​a​t​i​o​n + */ + invitation: string + /** + * I​n​v​i​t​e​ ​a​ ​f​r​i​e​n​d​ ​t​o​ ​F​l​a​s​h​! + */ + title: string + /** + * E​n​t​e​r​ ​a​ ​p​h​o​n​e​ ​n​u​m​b​e​r​ ​o​r​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s​ ​t​o​ ​i​n​v​i​t​e​ ​a​ ​f​r​i​e​n​d​.​ ​B​y​ ​i​n​v​i​t​i​n​g​ ​a​ ​f​r​i​e​n​d​,​ ​y​o​u​ ​c​o​n​f​i​r​m​ ​t​h​a​t​ ​t​h​e​ ​r​e​c​i​p​i​e​n​t​ ​h​a​s​ ​g​i​v​e​n​ ​c​o​n​s​e​n​t​ ​t​o​ ​r​e​c​e​i​v​e​ ​t​h​i​s​ ​i​n​v​i​t​a​t​i​o​n​. + */ + subtitle: string + /** + * E​n​t​e​r​ ​p​h​o​n​e​ ​n​u​m​b​e​r + */ + phoneNumber: string + /** + * E​n​t​e​r​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s + */ + email: string + /** + * I​n​v​i​t​e + */ + invite: string + /** + * O​R + */ + or: string + /** + * I​n​v​i​t​a​t​i​o​n​ ​h​a​s​ ​b​e​e​n​ ​s​e​n​t​ ​t​o​ ​{​v​a​l​u​e​} + * @param {string} value + */ + invitationSuccessTitle: RequiredParams<'value'> + /** + * D​o​n​e + */ + done: string } NotificationSettingsScreen: { /** @@ -6486,6 +6537,14 @@ export type TranslationFunctions = { * Backup your cash wallet and increase transaction limits. */ upgradeDesc: () => LocalizedString + /** + * Invite Friends + */ + inviteTitle: () => LocalizedString + /** + * Get rewards for inviting friends to Flash + */ + inviteDesc: () => LocalizedString /** * Change to your local currency */ @@ -7446,6 +7505,48 @@ export type TranslationFunctions = { * Hide Bitcoin account */ hideBtcAccount: () => LocalizedString + /** + * Invite a friend + */ + inviteFriend: () => LocalizedString + } + InviteFriend: { + /** + * Invitation + */ + invitation: () => LocalizedString + /** + * Invite a friend to Flash! + */ + title: () => LocalizedString + /** + * Enter a phone number or email address to invite a friend. By inviting a friend, you confirm that the recipient has given consent to receive this invitation. + */ + subtitle: () => LocalizedString + /** + * Enter phone number + */ + phoneNumber: () => LocalizedString + /** + * Enter email address + */ + email: () => LocalizedString + /** + * Invite + */ + invite: () => LocalizedString + /** + * OR + */ + or: () => LocalizedString + /** + * Invitation has been sent to {value} + */ + invitationSuccessTitle: (arg: { value: string }) => LocalizedString + /** + * Done + */ + done: () => LocalizedString } NotificationSettingsScreen: { /** diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index ea9650313..7e9cb15dc 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -521,6 +521,8 @@ "addFlashcard": "Add Flashcard", "upgradeTitle": "Add your phone number", "upgradeDesc": "Backup your cash wallet and increase transaction limits.", + "inviteTitle": "Invite Friends", + "inviteDesc": "Get rewards for inviting friends to Flash", "currencyTitle": "Change to your local currency", "currencyDesc": "Review our available currency list and select your currency.", "flashcardTitle": "Get a Flashcard", @@ -774,7 +776,19 @@ "advanceMode": "Enable Bitcoin Account (Advanced Mode)", "keysManagement": "Key management", "showBtcAccount": "Show Bitcoin account", - "hideBtcAccount": "Hide Bitcoin account" + "hideBtcAccount": "Hide Bitcoin account", + "inviteFriend": "Invite a friend" + }, + "InviteFriend": { + "invitation": "Invitation", + "title": "Invite a friend to Flash!", + "subtitle": "Enter a phone number or email address to invite a friend. By inviting a friend, you confirm that the recipient has given consent to receive this invitation.", + "phoneNumber": "Enter phone number", + "email": "Enter email address", + "invite": "Invite", + "or": "OR", + "invitationSuccessTitle": "Invitation has been sent to {value: string}", + "done": "Done" }, "NotificationSettingsScreen": { "title": "Notification Settings", diff --git a/app/navigation/navigation-container-wrapper.tsx b/app/navigation/navigation-container-wrapper.tsx index a093e123e..275c21658 100644 --- a/app/navigation/navigation-container-wrapper.tsx +++ b/app/navigation/navigation-container-wrapper.tsx @@ -89,10 +89,16 @@ export const NavigationContainerWrapper: React.FC = ({ Home: "/", }, }, + // Don't map invite URLs to any specific screen + // Let the InviteDeepLinkHandler handle them }, }, getInitialURL: async () => { const url = await Linking.getInitialURL() + // Don't let the navigation container handle invite URLs + if (url && url.includes("invite")) { + return null + } if (Boolean(url) && isAuthed && !isAppLocked) { return url } @@ -102,6 +108,11 @@ export const NavigationContainerWrapper: React.FC = ({ console.log("listener", listener) const onReceiveURL = ({ url }: { url: string }) => { console.log("onReceiveURL", url) + // Don't let the navigation container handle invite URLs + if (url && url.includes("invite")) { + console.log("Skipping invite URL in navigation container") + return + } listener(url) } // Listen to incoming links from deep linking diff --git a/app/navigation/root-navigator.tsx b/app/navigation/root-navigator.tsx index e431abb43..b12ce2082 100644 --- a/app/navigation/root-navigator.tsx +++ b/app/navigation/root-navigator.tsx @@ -87,6 +87,8 @@ import { TransactionHistoryTabs, USDTransactionHistory, SignInViaQRCode, + InviteFriend, + InviteFriendSuccess, } from "@app/screens" import { usePersistentStateContext } from "@app/store/persistent-state" import { NotificationSettingsScreen } from "@app/screens/settings-screen/notifications-screen" @@ -586,6 +588,16 @@ export const RootStack = () => { component={SupportGroupChatScreen} options={{ title: "Group Chat" }} /> + + ) } diff --git a/app/navigation/stack-param-lists.ts b/app/navigation/stack-param-lists.ts index cb8e8d321..c1e8485af 100644 --- a/app/navigation/stack-param-lists.ts +++ b/app/navigation/stack-param-lists.ts @@ -20,7 +20,12 @@ import { NavigatorScreenParams } from "@react-navigation/native" export type RootStackParamList = { Reconciliation: { from: string; to: string } - getStarted: undefined + getStarted?: { + inviteToken?: string + prefilledContact?: string + contactMethod?: string + inviterUsername?: string + } UsernameSet?: { insideApp?: boolean } Welcome: undefined welcomeFirst: undefined @@ -105,7 +110,11 @@ export type RootStackParamList = { lnurl: string } phoneFlow?: NavigatorScreenParams - phoneRegistrationInitiate: undefined + phoneRegistrationInitiate?: { + inviteToken?: string + prefilledPhone?: string + inviterUsername?: string + } phoneRegistrationValidate: { phone: string channel: PhoneCodeChannelType @@ -121,7 +130,11 @@ export type RootStackParamList = { accountScreen: undefined notificationSettingsScreen: undefined transactionLimitsScreen: undefined - emailRegistrationInitiate: undefined + emailRegistrationInitiate?: { + inviteToken?: string + prefilledEmail?: string + inviterUsername?: string + } emailRegistrationValidate: { email: string; emailRegistrationId: string } emailLoginInitiate: undefined emailLoginValidate: { email: string; emailLoginId: string } @@ -154,6 +167,11 @@ export type RootStackParamList = { Contacts: { userPrivateKey: string } SignInViaQRCode: undefined Nip29GroupChat: { groupId: string } + InviteFriend: undefined + InviteFriendSuccess?: { + contact: string + method: string + } } export type ChatStackParamList = { diff --git a/app/screens/authentication-screen/welcome.tsx b/app/screens/authentication-screen/welcome.tsx index dd9ab4068..452dd9e6a 100644 --- a/app/screens/authentication-screen/welcome.tsx +++ b/app/screens/authentication-screen/welcome.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useEffect } from "react" import { Text, Image, View } from "react-native" import { makeStyles, useTheme } from "@rneui/themed" import * as Animatable from "react-native-animatable" @@ -11,7 +11,8 @@ import AppLogo from "../../assets/logo/blink-logo-icon.png" // hooks import { useIsAuthed } from "@app/graphql/is-authed-context" -import { useAuthQuery } from "@app/graphql/generated" +import { useAuthQuery, useRedeemInviteMutation } from "@app/graphql/generated" +import { redeemPendingInvite } from "../invite-friend/HandleInviteDeepLink" // components import { PrimaryBtn } from "@app/components/buttons" @@ -24,6 +25,18 @@ export const Welcome: React.FC = ({ navigation }) => { const { colors } = useTheme().theme const { data } = useAuthQuery({ skip: !isAuthed }) + const [redeemInviteMutation] = useRedeemInviteMutation() + + useEffect(() => { + // Check and redeem pending invite when user reaches Welcome screen + if (isAuthed) { + redeemPendingInvite(redeemInviteMutation, true).then((result) => { + if (result.success) { + console.log("Invite redeemed successfully after onboarding") + } + }) + } + }, [isAuthed, redeemInviteMutation]) const onPressStart = () => { navigation.reset({ diff --git a/app/screens/get-started-screen/get-started-screen.tsx b/app/screens/get-started-screen/get-started-screen.tsx index d04ec929e..85bbe7bcf 100644 --- a/app/screens/get-started-screen/get-started-screen.tsx +++ b/app/screens/get-started-screen/get-started-screen.tsx @@ -30,7 +30,7 @@ const width = Dimensions.get("screen").width type Props = StackScreenProps -export const GetStartedScreen: React.FC = ({ navigation }) => { +export const GetStartedScreen: React.FC = ({ navigation, route }) => { const isFocused = useIsFocused() const { mode, colors } = useTheme().theme const { LL } = useI18nContext() @@ -43,6 +43,9 @@ export const GetStartedScreen: React.FC = ({ navigation }) => { const [error, setError] = useState(false) const [secretMenuCounter, setSecretMenuCounter] = useState(0) + const { inviteToken, prefilledContact, contactMethod, inviterUsername } = + route.params || {} + const AppLogo = mode === "dark" ? AppLogoDarkMode : AppLogoLightMode useEffect(() => { @@ -77,7 +80,26 @@ export const GetStartedScreen: React.FC = ({ navigation }) => { routes: [{ name: "authenticationCheck" }], }) } else { - navigation.navigate("phoneFlow") + // If we have an invite token, go to phone registration instead of login + if (inviteToken && prefilledContact) { + if (contactMethod === "EMAIL") { + navigation.navigate("emailRegistrationInitiate", { + inviteToken, + prefilledEmail: prefilledContact, + inviterUsername, + }) + } else { + // For SMS or WHATSAPP + navigation.navigate("phoneRegistrationInitiate", { + inviteToken, + prefilledPhone: prefilledContact, + inviterUsername, + }) + } + } else { + // No invite, go to normal phone login flow + navigation.navigate("phoneFlow") + } } } diff --git a/app/screens/index.ts b/app/screens/index.ts index 35a694751..ca17be404 100644 --- a/app/screens/index.ts +++ b/app/screens/index.ts @@ -1,3 +1,5 @@ export * from "./backup-screen" export * from "./import-wallet-screen" export * from "./transaction-history" +export * from "./settings-screen" +export * from "./invite-friend" diff --git a/app/screens/invite-friend/HandleInviteDeepLink.tsx b/app/screens/invite-friend/HandleInviteDeepLink.tsx new file mode 100644 index 000000000..bff13b2ed --- /dev/null +++ b/app/screens/invite-friend/HandleInviteDeepLink.tsx @@ -0,0 +1,172 @@ +import { useEffect } from "react" +import { Linking, Alert } from "react-native" +import { useNavigation, NavigationProp } from "@react-navigation/native" +import { + useRedeemInviteMutation, + useInvitePreviewLazyQuery, +} from "@app/graphql/generated" +import AsyncStorage from "@react-native-async-storage/async-storage" +import { useIsAuthed } from "@app/graphql/is-authed-context" +import { RootStackParamList } from "@app/navigation/stack-param-lists" + +export const useInviteDeepLink = () => { + const navigation = useNavigation>() + const [redeemInvite] = useRedeemInviteMutation() + const [fetchInvitePreview] = useInvitePreviewLazyQuery() + const isAuthed = useIsAuthed() + + useEffect(() => { + // Handle initial URL (when app is opened from link) + Linking.getInitialURL().then((url) => { + if (url) { + handleDeepLink(url) + } + }) + + // Handle URL changes (when app is already open) + const subscription = Linking.addEventListener("url", (event) => { + handleDeepLink(event.url) + }) + + return () => { + subscription.remove() + } + }, [isAuthed, navigation, redeemInvite, fetchInvitePreview]) + + const handleDeepLink = async (url: string) => { + // Only handle invite URLs + if (!url.includes("invite")) { + return + } + + // Parse the URL to get the token + // Expected format: flash://invite?token=xxxxx or https://getflash.io/invite?token=xxxxx + const tokenRegex = /[?&]token=([a-f0-9]{40})/ + + const tokenMatch = url.match(tokenRegex) + if (!tokenMatch || !tokenMatch[1]) { + return + } + + const token = tokenMatch[1] + + try { + // Store token for later use (after signup/login) + await AsyncStorage.setItem("pendingInviteToken", token) + + // Fetch invite preview to get details + const { data: previewData, error } = await fetchInvitePreview({ + variables: { token }, + fetchPolicy: "network-only", // Force fresh fetch, bypass cache + context: { + // Add a header to indicate this is the recipient requesting their own invite + headers: { + "X-Invite-Recipient": "true", + }, + }, + }) + + if (error) { + Alert.alert("Error", "Unable to fetch invitation details. Please try again.") + return + } + + if (!previewData?.invitePreview?.isValid) { + Alert.alert( + "Invalid Invitation", + "This invitation link is invalid or has expired.", + [{ text: "OK" }], + ) + return + } + + const { contact, method, inviterUsername } = previewData.invitePreview + + // Backend now returns full contact for the intended recipient + + if (isAuthed) { + // Existing user - show message but don't redeem + Alert.alert( + "Invitation for New Users", + `This invitation from ${ + inviterUsername || "a friend" + } is for new users only. Share it with friends who haven't joined yet!`, + [{ text: "OK" }], + ) + } else { + // If not logged in, navigate to phone login flow (which handles both login and registration) + // Store the invite details for use after successful authentication + // Add a small delay to ensure navigation is ready + setTimeout(() => { + if (method === "EMAIL") { + navigation.navigate("emailLoginInitiate", { + inviteToken: token, + prefilledEmail: contact, + inviterUsername: inviterUsername || undefined, + } as any) + } else { + // For SMS or WHATSAPP, go to phone login flow + navigation.navigate("phoneFlow", { + screen: "phoneLoginInitiate", + params: { + inviteToken: token, + prefilledPhone: contact, + inviterUsername: inviterUsername || undefined, + }, + } as any) + } + }, 500) + } + } catch (error) { + console.error("Error handling invite deep link:", error) + Alert.alert("Error", "Unable to process invitation. Please try again.") + } + } +} + +// Helper function to check and redeem pending invite after login +export const redeemPendingInvite = async ( + redeemInviteMutation: any, + showAlert = true, +) => { + try { + const token = await AsyncStorage.getItem("pendingInviteToken") + + if (!token) { + return { success: false, message: "No pending invite" } + } + + // Call the redeemInvite mutation + const { data } = await redeemInviteMutation({ + variables: { + input: { token }, + }, + }) + + // Clear the token after attempting redemption + await AsyncStorage.removeItem("pendingInviteToken") + + if (data?.redeemInvite?.success) { + if (showAlert) { + Alert.alert( + "Welcome!", + "You've successfully accepted the invitation and joined through a referral.", + [{ text: "OK" }], + ) + } + return { success: true, message: "Invite redeemed successfully" } + } else if (data?.redeemInvite?.errors?.[0]) { + const errorMessage = data.redeemInvite.errors[0] + // Only show alert for non-duplicate errors + if (showAlert && !errorMessage.includes("already been used")) { + Alert.alert("Notice", errorMessage) + } + return { success: false, message: errorMessage } + } + + return { success: false, message: "Unknown error" } + } catch (error) { + console.error("Error redeeming pending invite:", error) + return { success: false, message: "Error redeeming invite" } + } +} diff --git a/app/screens/invite-friend/InviteDeepLinkHandler.tsx b/app/screens/invite-friend/InviteDeepLinkHandler.tsx new file mode 100644 index 000000000..c068c5500 --- /dev/null +++ b/app/screens/invite-friend/InviteDeepLinkHandler.tsx @@ -0,0 +1,9 @@ +import React from "react" +import { useInviteDeepLink } from "./HandleInviteDeepLink" + +export const InviteDeepLinkHandler: React.FC = ({ + children, +}) => { + useInviteDeepLink() + return <>{children} +} diff --git a/app/screens/invite-friend/InviteFriend.tsx b/app/screens/invite-friend/InviteFriend.tsx new file mode 100644 index 000000000..c73b118fe --- /dev/null +++ b/app/screens/invite-friend/InviteFriend.tsx @@ -0,0 +1,502 @@ +import React, { useState } from "react" +import { View, Alert, TouchableOpacity, ScrollView } from "react-native" +import { makeStyles, Text, useTheme } from "@rneui/themed" +import { StackScreenProps } from "@react-navigation/stack" +import { CountryCode } from "react-native-country-picker-modal" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { useI18nContext } from "@app/i18n/i18n-react" +import { + parsePhoneNumber, + CountryCode as PhoneNumberCountryCode, +} from "libphonenumber-js/mobile" +import validator from "validator" +import { useCreateInviteMutation, InviteMethod } from "@app/graphql/generated" +import Icon from "react-native-vector-icons/Ionicons" + +// components +import { Screen } from "@app/components/screen" +import { PrimaryBtn } from "@app/components/buttons" +import { EmailInput, PhoneNumberInput } from "@app/components/input" +import { ContactPicker } from "@app/components/contact-picker" + +type Props = StackScreenProps +type InputMethod = "contacts" | "phone" | "email" + +const InviteFriend: React.FC = ({ navigation }) => { + const { LL } = useI18nContext() + const styles = useStyles() + const { colors } = useTheme().theme + + // State for input method selection + const [inputMethod, setInputMethod] = useState("contacts") + + // State for different input types + const [countryCode, setCountryCode] = useState("JM") + const [phoneNumber, setPhoneNumber] = useState() + const [email, setEmail] = useState() + const [loading, setLoading] = useState(false) + const [showContactPicker, setShowContactPicker] = useState(false) + + const [createInvite] = useCreateInviteMutation() + + const handleContactSelect = (selectedValue: string, type: "phone" | "email") => { + if (type === "email") { + // Handle email selection + setEmail(selectedValue) + setInputMethod("email") + } else { + // Handle phone number selection + try { + const parsed = parsePhoneNumber(selectedValue) + if (parsed && parsed.isValid()) { + setCountryCode(parsed.country as CountryCode) + setPhoneNumber(parsed.nationalNumber) + // Automatically switch to phone tab after selecting contact + setInputMethod("phone") + } else { + // If parsing fails, just set the raw number + const cleanNumber = selectedValue.replace(/[^\d+]/g, "") + if (cleanNumber.startsWith("+")) { + // Try to extract country code from international format + // Handle common Caribbean countries with +1 + if (cleanNumber.startsWith("+1876")) { + setCountryCode("JM" as CountryCode) + setPhoneNumber(cleanNumber.replace("+1", "")) + } else if (cleanNumber.startsWith("+1868")) { + setCountryCode("TT" as CountryCode) + setPhoneNumber(cleanNumber.replace("+1", "")) + } else if (cleanNumber.startsWith("+1246")) { + setCountryCode("BB" as CountryCode) + setPhoneNumber(cleanNumber.replace("+1", "")) + } else if (cleanNumber.startsWith("+1")) { + // Default to US for other +1 numbers + setCountryCode("US" as CountryCode) + setPhoneNumber(cleanNumber.replace("+1", "")) + } else { + // Remove + and set as phone number + setPhoneNumber(cleanNumber.replace("+", "")) + } + } else { + setPhoneNumber(cleanNumber) + } + // Switch to phone tab + setInputMethod("phone") + } + } catch (error) { + console.error("Error parsing contact phone number:", error) + // Just set the number as-is + const cleanNumber = selectedValue.replace(/[^\d]/g, "") + setPhoneNumber(cleanNumber) + setInputMethod("phone") + } + } + setShowContactPicker(false) + } + + const onSubmit = async () => { + // Validate inputs based on selected method + let contact: string = "" + let method: InviteMethod = InviteMethod.Email + + if (inputMethod === "email") { + if (email && validator.isEmail(email)) { + contact = email + method = InviteMethod.Email + } else { + Alert.alert("Error", "Please enter a valid email address") + return + } + } else if (inputMethod === "phone" || inputMethod === "contacts") { + if (countryCode && phoneNumber) { + const parsedPhoneNumber = parsePhoneNumber( + phoneNumber, + countryCode as PhoneNumberCountryCode, + ) + if (parsedPhoneNumber?.isValid()) { + contact = parsedPhoneNumber.format("E.164") + method = InviteMethod.Whatsapp + } else { + Alert.alert("Error", "Please enter a valid phone number") + return + } + } else { + Alert.alert("Error", "Please enter a phone number") + return + } + } + + if (!contact) { + Alert.alert("Error", "Please enter contact information") + return + } + + // Send invite + setLoading(true) + try { + const { data } = await createInvite({ + variables: { + input: { + contact, + method, + }, + }, + }) + + if (data?.createInvite?.invite) { + navigation.navigate("InviteFriendSuccess", { + contact: data.createInvite.invite.contact, + method: data.createInvite.invite.method, + }) + } else if (data?.createInvite?.errors && data.createInvite.errors.length > 0) { + Alert.alert("Error", data.createInvite.errors[0]) + } + } catch (error) { + console.error("Error sending invite:", error) + Alert.alert("Error", "Unable to send invitation. Please try again.") + } finally { + setLoading(false) + } + } + + const isButtonDisabled = () => { + if (loading) return true + + if (inputMethod === "email") { + return !email || !validator.isEmail(email) + } else if (inputMethod === "phone" || inputMethod === "contacts") { + return !phoneNumber || phoneNumber.length === 0 + } + + return true + } + + const renderInputContent = () => { + switch (inputMethod) { + case "contacts": + return ( + + setShowContactPicker(true)} + > + + + {phoneNumber || email + ? phoneNumber + ? `Phone: ${phoneNumber}` + : `Email: ${email}` + : "Tap to select from contacts"} + + + Choose a friend from your phone's contact list + + + + ) + + case "phone": + return ( + + + + {/* WhatsApp indicator */} + + + + Invitation will be sent via WhatsApp + + + + ) + + case "email": + return ( + + + + We'll send an invitation link to this email address + + + ) + + default: + return null + } + } + + return ( + + + {/* Header */} + + + {LL.InviteFriend.title()} + + + {LL.InviteFriend.subtitle()} + + + + {/* Icon-based Method Selector */} + + Choose how to invite: + + setInputMethod("contacts")} + > + + + + + Contacts + + + + setInputMethod("phone")} + > + + + + + WhatsApp + + + + setInputMethod("email")} + > + + + + + Email + + + + + + {/* Input Content Area */} + {renderInputContent()} + + + {/* Submit Button */} + + + {/* Contact Picker Modal */} + setShowContactPicker(false)} + onSelectContact={handleContactSelect} + /> + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + screenStyle: { + padding: 20, + flexGrow: 1, + }, + main: { + flex: 1, + }, + header: { + marginBottom: 24, + }, + title: { + marginBottom: 12, + fontSize: 28, + }, + subtitle: { + color: colors.grey3, + fontSize: 16, + lineHeight: 22, + }, + selectorContainer: { + marginBottom: 32, + }, + selectorTitle: { + fontSize: 16, + fontWeight: "600", + color: colors.black, + marginBottom: 20, + textAlign: "center", + }, + iconSelector: { + flexDirection: "row", + justifyContent: "space-around", + paddingHorizontal: 10, + }, + iconButton: { + alignItems: "center", + opacity: 0.7, + }, + iconButtonActive: { + opacity: 1, + }, + iconCircle: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.grey5, + alignItems: "center", + justifyContent: "center", + borderWidth: 2, + borderColor: colors.grey4, + marginBottom: 8, + }, + iconCircleActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + shadowColor: colors.primary, + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + iconLabel: { + fontSize: 14, + fontWeight: "500", + color: colors.grey2, + }, + iconLabelActive: { + color: colors.primary, + fontWeight: "700", + }, + inputContainer: { + minHeight: 200, + marginBottom: 24, + }, + inputContent: { + paddingTop: 16, + }, + selectContactButton: { + alignItems: "center", + justifyContent: "center", + paddingVertical: 32, + paddingHorizontal: 24, + borderRadius: 12, + backgroundColor: colors.grey5, + borderWidth: 2, + borderColor: colors.primary, + borderStyle: "dashed", + }, + selectContactIcon: { + color: colors.primary, + }, + selectContactText: { + marginTop: 16, + fontSize: 18, + fontWeight: "600", + color: colors.black, + textAlign: "center", + }, + selectContactSubtext: { + marginTop: 8, + fontSize: 14, + color: colors.grey3, + textAlign: "center", + }, + whatsappIndicator: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + marginTop: 16, + paddingVertical: 8, + paddingHorizontal: 12, + backgroundColor: colors.grey5, + borderRadius: 8, + }, + whatsappText: { + fontSize: 14, + color: colors.grey1, + marginLeft: 8, + fontWeight: "500", + }, + inputHint: { + marginTop: 12, + fontSize: 14, + color: colors.grey3, + textAlign: "center", + paddingHorizontal: 16, + }, + submitButton: { + marginBottom: 10, + }, +})) + +export default InviteFriend diff --git a/app/screens/invite-friend/InviteFriendSuccess.tsx b/app/screens/invite-friend/InviteFriendSuccess.tsx new file mode 100644 index 000000000..e16f53d6a --- /dev/null +++ b/app/screens/invite-friend/InviteFriendSuccess.tsx @@ -0,0 +1,64 @@ +import React from "react" +import { Dimensions, View } from "react-native" +import { makeStyles, Text, useTheme } from "@rneui/themed" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { StackScreenProps } from "@react-navigation/stack" +import { useI18nContext } from "@app/i18n/i18n-react" + +// components +import { Screen } from "@app/components/screen" +import { PrimaryBtn } from "@app/components/buttons" + +// assets +import SendSuccess from "@app/assets/illustrations/send-success.svg" + +const width = Dimensions.get("window").width + +type Props = StackScreenProps + +const InviteFriendSuccess: React.FC = ({ navigation, route }) => { + const { LL } = useI18nContext() + const { bottom } = useSafeAreaInsets() + const { colors } = useTheme().theme + const styles = useStyles() + + // Get the contact from route params, or use default + const contact = route.params?.contact || "your friend" + const method = route.params?.method || "EMAIL" + + const onPressDone = () => { + navigation.popToTop() + } + + return ( + + + + + {LL.InviteFriend.invitationSuccessTitle({ value: contact })} + + + + + ) +} + +export default InviteFriendSuccess + +const useStyles = makeStyles(() => ({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, +})) diff --git a/app/screens/invite-friend/index.ts b/app/screens/invite-friend/index.ts new file mode 100644 index 000000000..8ecb22e96 --- /dev/null +++ b/app/screens/invite-friend/index.ts @@ -0,0 +1,4 @@ +import InviteFriend from "./InviteFriend" +import InviteFriendSuccess from "./InviteFriendSuccess" + +export { InviteFriend, InviteFriendSuccess } diff --git a/app/screens/phone-auth-screen/phone-login-input.tsx b/app/screens/phone-auth-screen/phone-login-input.tsx index 90fae5f17..469063dfa 100644 --- a/app/screens/phone-auth-screen/phone-login-input.tsx +++ b/app/screens/phone-auth-screen/phone-login-input.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react" +import React, { useEffect, useState } from "react" import { ActivityIndicator, View } from "react-native" import { StackScreenProps } from "@react-navigation/stack" import { TouchableOpacity } from "react-native-gesture-handler" @@ -37,11 +37,16 @@ const PLACEHOLDER_PHONE_NUMBER = "123-456-7890" type Props = StackScreenProps -export const PhoneLoginInitiateScreen: React.FC = ({ navigation }) => { +export const PhoneLoginInitiateScreen: React.FC = ({ navigation, route }) => { const { LL } = useI18nContext() const { colors, mode } = useTheme().theme const styles = useStyles() + // Check if this is an invite signup + const hasInviteToken = !!(route?.params as any)?.inviteToken + console.log("PhoneLoginInitiate: Route params:", JSON.stringify(route?.params)) + console.log("PhoneLoginInitiate: Has invite token:", hasInviteToken) + const { submitPhoneNumber, captchaLoading, @@ -57,10 +62,102 @@ export const PhoneLoginInitiateScreen: React.FC = ({ navigation }) => { setCountryCode, supportedCountries, loadingSupportedCountries, - } = useRequestPhoneCodeLogin() + } = useRequestPhoneCodeLogin(hasInviteToken) + + // Handle pre-filled phone from invite + const [hasPreFilled, setHasPreFilled] = React.useState(false) + const [targetCountryCode, setTargetCountryCode] = React.useState< + CountryCode | undefined + >() + + useEffect(() => { + const handlePrefilledPhone = async () => { + const params = route?.params as any + + // Only pre-fill once and if we have the phone number parameter + if (params?.prefilledPhone && !hasPreFilled) { + setHasPreFilled(true) + + try { + // Import parsePhoneNumber at the top of the file + const { parsePhoneNumber } = await import("libphonenumber-js/mobile") + + // Check for specific Caribbean country codes + if (params.prefilledPhone.startsWith("+1876")) { + // Jamaica + const numberWithoutCountryCode = params.prefilledPhone.replace("+1", "") + setPhoneNumber(numberWithoutCountryCode) + setTargetCountryCode("JM" as CountryCode) + } else if (params.prefilledPhone.startsWith("+1868")) { + // Trinidad and Tobago + const numberWithoutCountryCode = params.prefilledPhone.replace("+1", "") + setPhoneNumber(numberWithoutCountryCode) + setTargetCountryCode("TT" as CountryCode) + } else if (params.prefilledPhone.startsWith("+1246")) { + // Barbados + const numberWithoutCountryCode = params.prefilledPhone.replace("+1", "") + setPhoneNumber(numberWithoutCountryCode) + setTargetCountryCode("BB" as CountryCode) + } else { + // Try parsing with libphonenumber + const parsed = parsePhoneNumber(params.prefilledPhone) + if (parsed && parsed.isValid()) { + const countryCode = parsed.country as CountryCode + const nationalNumber = parsed.nationalNumber + setTargetCountryCode(countryCode) + setPhoneNumber(nationalNumber) + } else { + // If parsing fails, try to extract what we can + if (params.prefilledPhone.startsWith("+1")) { + // Default to US for other +1 numbers + setTargetCountryCode("US" as CountryCode) + const numberWithoutCountry = params.prefilledPhone.replace("+1", "") + setPhoneNumber(numberWithoutCountry) + } else { + // Just set the number without country code + const cleanNumber = params.prefilledPhone.replace(/[^\d]/g, "") + setPhoneNumber(cleanNumber) + } + } + } + + // Store invite token for later redemption + if (params.inviteToken) { + const AsyncStorage = ( + await import("@react-native-async-storage/async-storage") + ).default + await AsyncStorage.setItem("pendingInviteToken", params.inviteToken) + } + } catch (error) { + console.error("Error parsing pre-filled phone:", error) + // If all else fails, just set what we have + const cleanNumber = params.prefilledPhone.replace(/[^\d]/g, "") + setPhoneNumber(cleanNumber) + } + } + } + + handlePrefilledPhone() + }, [route?.params, setPhoneNumber, hasPreFilled]) + + // Set country code when status changes and we have a target + useEffect(() => { + if (status === RequestPhoneCodeStatus.InputtingPhoneNumber && targetCountryCode) { + // Add a small delay to ensure the component is ready + setTimeout(() => { + setCountryCode(targetCountryCode) + // Clear the target after setting + setTargetCountryCode(undefined) + }, 100) + } + }, [status, targetCountryCode, setCountryCode]) useEffect(() => { + console.log("PhoneLoginInitiate: Status changed to:", status) if (status === RequestPhoneCodeStatus.SuccessRequestingCode) { + console.log("PhoneLoginInitiate: Success! Navigating to validation screen") + console.log("Phone:", validatedPhoneNumber) + console.log("Channel:", phoneCodeChannel) setStatus(RequestPhoneCodeStatus.InputtingPhoneNumber) navigation.navigate("phoneLoginValidate", { phone: validatedPhoneNumber || "", diff --git a/app/screens/phone-auth-screen/phone-registration-input.tsx b/app/screens/phone-auth-screen/phone-registration-input.tsx index d968682b9..20bf34df1 100644 --- a/app/screens/phone-auth-screen/phone-registration-input.tsx +++ b/app/screens/phone-auth-screen/phone-registration-input.tsx @@ -9,6 +9,7 @@ import CountryPicker, { import { CountryCode as PhoneNumberCountryCode, getCountryCallingCode, + parsePhoneNumber, } from "libphonenumber-js/mobile" import { ContactSupportButton } from "@app/components/contact-support-button/contact-support-button" import { useI18nContext } from "@app/i18n/i18n-react" @@ -24,11 +25,19 @@ import { GaloySecondaryButton } from "@app/components/atomic/galoy-secondary-but import { GaloyErrorBox } from "@app/components/atomic/galoy-error-box" import { PhoneCodeChannelType } from "@app/graphql/generated" import { TouchableOpacity } from "react-native-gesture-handler" +import { RouteProp, useRoute } from "@react-navigation/native" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import AsyncStorage from "@react-native-async-storage/async-storage" const DEFAULT_COUNTRY_CODE = "SV" const PLACEHOLDER_PHONE_NUMBER = "123-456-7890" -export const PhoneRegistrationInitiateScreen: React.FC = () => { +type Props = { + route?: RouteProp +} + +export const PhoneRegistrationInitiateScreen: React.FC = () => { + const route = useRoute>() const styles = useStyles() const { @@ -50,6 +59,96 @@ export const PhoneRegistrationInitiateScreen: React.FC = () => { const { LL } = useI18nContext() + // State to track if we've already pre-filled + const [hasPreFilled, setHasPreFilled] = React.useState(false) + const [targetCountryCode, setTargetCountryCode] = React.useState< + CountryCode | undefined + >() + + // Handle pre-filled phone from invite + React.useEffect(() => { + const handlePrefilledPhone = async () => { + const params = route.params as any + + // Only pre-fill once and if we have the phone number parameter + if (params?.prefilledPhone && !hasPreFilled) { + setHasPreFilled(true) + + try { + // First check for specific country codes that might be problematic + // Jamaica numbers start with +1876 + if (params.prefilledPhone.startsWith("+1876")) { + // Jamaica - keep the area code 876 as part of the phone number + // Remove only the +1 country code + const numberWithoutCountryCode = params.prefilledPhone.replace("+1", "") + + // Set phone number immediately + setPhoneNumber(numberWithoutCountryCode) + + // Store the target country code to set later + setTargetCountryCode("JM" as CountryCode) + } else if (params.prefilledPhone.startsWith("+1868")) { + // Trinidad and Tobago + const numberWithoutCountryCode = params.prefilledPhone.replace("+1", "") + setPhoneNumber(numberWithoutCountryCode) + setTargetCountryCode("TT" as CountryCode) + } else if (params.prefilledPhone.startsWith("+1246")) { + // Barbados + const numberWithoutCountryCode = params.prefilledPhone.replace("+1", "") + setPhoneNumber(numberWithoutCountryCode) + setTargetCountryCode("BB" as CountryCode) + } else { + // Try parsing with libphonenumber + const parsed = parsePhoneNumber(params.prefilledPhone) + if (parsed && parsed.isValid()) { + const countryCode = parsed.country as CountryCode + const nationalNumber = parsed.nationalNumber + + setTargetCountryCode(countryCode) + setPhoneNumber(nationalNumber) + } else { + // If parsing fails, try to extract what we can + if (params.prefilledPhone.startsWith("+1")) { + // Default to US for other +1 numbers + setTargetCountryCode("US" as CountryCode) + const numberWithoutCountry = params.prefilledPhone.replace("+1", "") + setPhoneNumber(numberWithoutCountry) + } else { + // Just set the number without country code + const cleanNumber = params.prefilledPhone.replace(/[^\d]/g, "") + setPhoneNumber(cleanNumber) + } + } + } + + // Store invite token for later redemption + if (params.inviteToken) { + await AsyncStorage.setItem("pendingInviteToken", params.inviteToken) + } + } catch (error) { + console.error("Error parsing pre-filled phone:", error) + // If all else fails, just set what we have + const cleanNumber = params.prefilledPhone.replace(/[^\d]/g, "") + setPhoneNumber(cleanNumber) + } + } + } + + handlePrefilledPhone() + }, [route.params, setPhoneNumber, hasPreFilled]) + + // Set country code when status changes to InputtingPhoneNumber and we have a target + React.useEffect(() => { + if (status === RequestPhoneCodeStatus.InputtingPhoneNumber && targetCountryCode) { + // Add a small delay to ensure the component is ready + setTimeout(() => { + setCountryCode(targetCountryCode) + // Clear the target after setting + setTargetCountryCode(undefined) + }, 100) + } + }, [status, targetCountryCode, setCountryCode]) + if (status === RequestPhoneCodeStatus.LoadingCountryCode) { return ( diff --git a/app/screens/phone-auth-screen/request-phone-code-login.ts b/app/screens/phone-auth-screen/request-phone-code-login.ts index 8d229779b..f309ff21d 100644 --- a/app/screens/phone-auth-screen/request-phone-code-login.ts +++ b/app/screens/phone-auth-screen/request-phone-code-login.ts @@ -10,6 +10,7 @@ import { gql } from "@apollo/client" import { PhoneCodeChannelType, useCaptchaRequestAuthCodeMutation, + useUserPhoneRegistrationInitiateMutation, useSupportedCountriesQuery, } from "@app/graphql/generated" @@ -87,7 +88,10 @@ gql` } ` -export const useRequestPhoneCodeLogin = (): UseRequestPhoneCodeReturn => { +export const useRequestPhoneCodeLogin = ( + isInviteSignup = false, +): UseRequestPhoneCodeReturn => { + console.log("useRequestPhoneCodeLogin: isInviteSignup =", isInviteSignup) const [status, setStatus] = useState( RequestPhoneCodeStatus.LoadingCountryCode, ) @@ -102,6 +106,7 @@ export const useRequestPhoneCodeLogin = (): UseRequestPhoneCodeReturn => { const [error, setError] = useState() const [captchaRequestAuthCode] = useCaptchaRequestAuthCodeMutation() + const [userPhoneRegistrationInitiate] = useUserPhoneRegistrationInitiateMutation() const { data, loading: loadingSupportedCountries } = useSupportedCountriesQuery() const { isWhatsAppSupported, isSmsSupported, allSupportedCountries } = useMemo(() => { @@ -157,7 +162,13 @@ export const useRequestPhoneCodeLogin = (): UseRequestPhoneCodeReturn => { // Handle the error gracefully by not logging it } - setCountryCode(defaultCountryCode) + // Only set country code if not already set (e.g., from pre-filled invite) + setCountryCode((prevCountryCode) => { + if (prevCountryCode) { + return prevCountryCode + } + return defaultCountryCode + }) setStatus(RequestPhoneCodeStatus.InputtingPhoneNumber) } @@ -182,15 +193,29 @@ export const useRequestPhoneCodeLogin = (): UseRequestPhoneCodeReturn => { setStatus(RequestPhoneCodeStatus.InputtingPhoneNumber) } - const submitPhoneNumber = (phoneCodeChannel: PhoneCodeChannelType) => { + const submitPhoneNumber = async (phoneCodeChannel: PhoneCodeChannelType) => { + console.log("submitPhoneNumber called with channel:", phoneCodeChannel) + console.log("Current status:", status) + console.log("isInviteSignup:", isInviteSignup) + console.log("rawPhoneNumber:", rawPhoneNumber) + console.log("countryCode:", countryCode) + if ( status === RequestPhoneCodeStatus.LoadingCountryCode || status === RequestPhoneCodeStatus.RequestingCode ) { + console.log("submitPhoneNumber: Returning early due to status:", status) return } const parsedPhoneNumber = parsePhoneNumber(rawPhoneNumber, countryCode) + console.log( + "Parsed phone number:", + parsedPhoneNumber?.number, + "isValid:", + parsedPhoneNumber?.isValid(), + ) + phoneCodeChannel && setPhoneCodeChannel(phoneCodeChannel) if (parsedPhoneNumber?.isValid()) { if ( @@ -198,20 +223,64 @@ export const useRequestPhoneCodeLogin = (): UseRequestPhoneCodeReturn => { (phoneCodeChannel === PhoneCodeChannelType.Sms && !isSmsSupported) || (phoneCodeChannel === PhoneCodeChannelType.Whatsapp && !isWhatsAppSupported) ) { + console.log("submitPhoneNumber: Unsupported country/channel") + console.log("Country:", parsedPhoneNumber.country) + console.log("isSmsSupported:", isSmsSupported) + console.log("isWhatsAppSupported:", isWhatsAppSupported) setStatus(RequestPhoneCodeStatus.Error) setError(ErrorType.UnsupportedCountryError) return } setValidatedPhoneNumber(parsedPhoneNumber.number) + console.log("Set validated phone number:", parsedPhoneNumber.number) if (skipRequestPhoneCode) { + console.log("Skipping request phone code (local environment)") setStatus(RequestPhoneCodeStatus.SuccessRequestingCode) return } - setStatus(RequestPhoneCodeStatus.CompletingCaptcha) - registerCaptcha() + // If this is an invite signup, use registration flow (no captcha needed) + if (isInviteSignup) { + console.log("=== INVITE SIGNUP FLOW - USING REGISTRATION API ===") + console.log("About to call userPhoneRegistrationInitiate mutation") + console.log("Phone number:", parsedPhoneNumber.number) + console.log("Channel:", phoneCodeChannel) + + setStatus(RequestPhoneCodeStatus.RequestingCode) + try { + console.log("Calling userPhoneRegistrationInitiate mutation...") + const res = await userPhoneRegistrationInitiate({ + variables: { + input: { phone: parsedPhoneNumber.number, channel: phoneCodeChannel }, + }, + }) + + console.log("Registration API response:", JSON.stringify(res.data)) + + if (res.data?.userPhoneRegistrationInitiate?.errors?.length) { + console.log( + "Registration API returned errors:", + res.data.userPhoneRegistrationInitiate.errors, + ) + setStatus(RequestPhoneCodeStatus.Error) + setError(ErrorType.RequestCodeError) + } else { + console.log("Registration API SUCCESS - code should be sent via Twilio") + setStatus(RequestPhoneCodeStatus.SuccessRequestingCode) + } + } catch (error) { + console.error("Error requesting registration code:", error) + setStatus(RequestPhoneCodeStatus.Error) + setError(ErrorType.RequestCodeError) + } + } else { + // Regular login flow with captcha + console.log("=== REGULAR LOGIN FLOW - USING CAPTCHA ===") + setStatus(RequestPhoneCodeStatus.CompletingCaptcha) + registerCaptcha() + } } else { setStatus(RequestPhoneCodeStatus.Error) setError(ErrorType.InvalidPhoneNumberError) diff --git a/app/screens/phone-auth-screen/request-phone-code-registration.ts b/app/screens/phone-auth-screen/request-phone-code-registration.ts index b36210b8b..5209ada46 100644 --- a/app/screens/phone-auth-screen/request-phone-code-registration.ts +++ b/app/screens/phone-auth-screen/request-phone-code-registration.ts @@ -150,7 +150,13 @@ export const useRequestPhoneCodeRegistration = (): UseRequestPhoneCodeReturn => console.error(error) } - setCountryCode(defaultCountryCode) + // Only set country code if not already set + setCountryCode((prevCountryCode) => { + if (prevCountryCode) { + return prevCountryCode + } + return defaultCountryCode + }) setStatus(RequestPhoneCodeStatus.InputtingPhoneNumber) } diff --git a/app/screens/settings-screen/settings-screen.tsx b/app/screens/settings-screen/settings-screen.tsx index 00991bcbf..ccc4caa7f 100644 --- a/app/screens/settings-screen/settings-screen.tsx +++ b/app/screens/settings-screen/settings-screen.tsx @@ -31,6 +31,7 @@ import { GenerateReportsSetting } from "./settings/generate-reports" import { SettingsGroup } from "./group" import { EmailSetting } from "./account/settings/email" import { ChatSetting } from "./chat-setting" +import { InviteFriendSetting } from "./settings/invite-friend" // import { TotpSetting } from "./totp" gql` @@ -81,7 +82,7 @@ const items = { ExportCsvSetting, // ApiAccessSetting ], - community: [JoinCommunitySetting], + community: [JoinCommunitySetting, InviteFriendSetting], } export const SettingsScreen: React.FC = () => { diff --git a/app/screens/settings-screen/settings/invite-friend.tsx b/app/screens/settings-screen/settings/invite-friend.tsx new file mode 100644 index 000000000..4ada7eb31 --- /dev/null +++ b/app/screens/settings-screen/settings/invite-friend.tsx @@ -0,0 +1,24 @@ +import { useI18nContext } from "@app/i18n/i18n-react" +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" +import { RootStackParamList } from "@app/navigation/stack-param-lists" + +// components +import { SettingsRow } from "../row" + +export const InviteFriendSetting: React.FC = () => { + const { LL } = useI18nContext() + const navigation = useNavigation>() + + const onNavigate = () => { + navigation.navigate("InviteFriend") + } + + return ( + + ) +} diff --git a/ios/LNFlash.xcodeproj/project.pbxproj b/ios/LNFlash.xcodeproj/project.pbxproj index a0c1ea64c..adbce65b8 100644 --- a/ios/LNFlash.xcodeproj/project.pbxproj +++ b/ios/LNFlash.xcodeproj/project.pbxproj @@ -380,6 +380,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PromisesSwift/Promises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNImageCropPicker/QBImagePicker.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNPermissions/RNPermissionsPrivacyInfo.bundle", "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf", "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf", "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf", @@ -419,6 +420,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Promises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/QBImagePicker.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNPermissionsPrivacyInfo.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf", @@ -557,6 +559,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PromisesSwift/Promises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNImageCropPicker/QBImagePicker.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNPermissions/RNPermissionsPrivacyInfo.bundle", "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf", "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf", "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf", @@ -596,6 +599,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Promises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/QBImagePicker.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNPermissionsPrivacyInfo.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf", diff --git a/ios/LNFlash/Info.plist b/ios/LNFlash/Info.plist index f7a9a9823..0f75210d6 100644 --- a/ios/LNFlash/Info.plist +++ b/ios/LNFlash/Info.plist @@ -101,6 +101,8 @@ NSCameraUsageDescription Activate your camera to scan QR codes + NSContactsUsageDescription + Access your contacts to easily invite friends to Flash NSFaceIDUsageDescription $(PRODUCT_NAME) requires FaceID access for quick and secure authentication. NSLocationWhenInUseUsageDescription diff --git a/ios/Podfile b/ios/Podfile index 43f1cf4b4..f72d04cda 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,9 +1,21 @@ require_relative '../node_modules/react-native/scripts/react_native_pods' require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' +require_relative '../node_modules/react-native-permissions/scripts/setup' + use_modular_headers! platform :ios, '13.4' prepare_react_native_project! + + +# ⬇️ react-native-permissions setup +setup_permissions([ + 'Contacts', + 'Camera', + 'PhotoLibrary', + 'LocationWhenInUse', + 'FaceID' +]) # If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set. # because `react-native-flipper` depends on (FlipperKit,...) that will be excluded # diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 68e37c227..21a74446e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -13,14 +13,14 @@ PODS: - BVLinearGradient (2.8.3): - React-Core - DoubleConversion (1.1.6) - - FBLazyVector (0.72.9) - - FBReactNativeSpec (0.72.9): + - FBLazyVector (0.72.7) + - FBReactNativeSpec (0.72.7): - RCT-Folly (= 2021.07.22.00) - - RCTRequired (= 0.72.9) - - RCTTypeSafety (= 0.72.9) - - React-Core (= 0.72.9) - - React-jsi (= 0.72.9) - - ReactCommon/turbomodule/core (= 0.72.9) + - RCTRequired (= 0.72.7) + - RCTTypeSafety (= 0.72.7) + - React-Core (= 0.72.7) + - React-jsi (= 0.72.7) + - ReactCommon/turbomodule/core (= 0.72.7) - Firebase/AnalyticsWithoutAdIdSupport (11.12.0): - Firebase/CoreOnly - FirebaseAnalytics/WithoutAdIdSupport (~> 11.12.0) @@ -158,9 +158,9 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - GT3Captcha-iOS (0.15.9) - - hermes-engine (0.72.9): - - hermes-engine/Pre-built (= 0.72.9) - - hermes-engine/Pre-built (0.72.9) + - hermes-engine (0.72.7): + - hermes-engine/Pre-built (= 0.72.7) + - hermes-engine/Pre-built (0.72.7) - libevent (2.1.12) - nanopb (3.30910.0): - nanopb/decode (= 3.30910.0) @@ -188,26 +188,26 @@ PODS: - fmt (~> 6.2.1) - glog - libevent - - RCTRequired (0.72.9) - - RCTTypeSafety (0.72.9): - - FBLazyVector (= 0.72.9) - - RCTRequired (= 0.72.9) - - React-Core (= 0.72.9) - - React (0.72.9): - - React-Core (= 0.72.9) - - React-Core/DevSupport (= 0.72.9) - - React-Core/RCTWebSocket (= 0.72.9) - - React-RCTActionSheet (= 0.72.9) - - React-RCTAnimation (= 0.72.9) - - React-RCTBlob (= 0.72.9) - - React-RCTImage (= 0.72.9) - - React-RCTLinking (= 0.72.9) - - React-RCTNetwork (= 0.72.9) - - React-RCTSettings (= 0.72.9) - - React-RCTText (= 0.72.9) - - React-RCTVibration (= 0.72.9) - - React-callinvoker (0.72.9) - - React-Codegen (0.72.9): + - RCTRequired (0.72.7) + - RCTTypeSafety (0.72.7): + - FBLazyVector (= 0.72.7) + - RCTRequired (= 0.72.7) + - React-Core (= 0.72.7) + - React (0.72.7): + - React-Core (= 0.72.7) + - React-Core/DevSupport (= 0.72.7) + - React-Core/RCTWebSocket (= 0.72.7) + - React-RCTActionSheet (= 0.72.7) + - React-RCTAnimation (= 0.72.7) + - React-RCTBlob (= 0.72.7) + - React-RCTImage (= 0.72.7) + - React-RCTLinking (= 0.72.7) + - React-RCTNetwork (= 0.72.7) + - React-RCTSettings (= 0.72.7) + - React-RCTText (= 0.72.7) + - React-RCTVibration (= 0.72.7) + - React-callinvoker (0.72.7) + - React-Codegen (0.72.7): - DoubleConversion - FBReactNativeSpec - glog @@ -222,11 +222,11 @@ PODS: - React-rncore - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - React-Core (0.72.9): + - React-Core (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-Core/Default (= 0.72.9) + - React-Core/Default (= 0.72.7) - React-cxxreact - React-hermes - React-jsi @@ -236,7 +236,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/CoreModulesHeaders (0.72.9): + - React-Core/CoreModulesHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -250,7 +250,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/Default (0.72.9): + - React-Core/Default (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -263,23 +263,23 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/DevSupport (0.72.9): + - React-Core/DevSupport (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-Core/Default (= 0.72.9) - - React-Core/RCTWebSocket (= 0.72.9) + - React-Core/Default (= 0.72.7) + - React-Core/RCTWebSocket (= 0.72.7) - React-cxxreact - React-hermes - React-jsi - React-jsiexecutor - - React-jsinspector (= 0.72.9) + - React-jsinspector (= 0.72.7) - React-perflogger - React-runtimeexecutor - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTActionSheetHeaders (0.72.9): + - React-Core/RCTActionSheetHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -293,7 +293,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTAnimationHeaders (0.72.9): + - React-Core/RCTAnimationHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -307,7 +307,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTBlobHeaders (0.72.9): + - React-Core/RCTBlobHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -321,7 +321,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTImageHeaders (0.72.9): + - React-Core/RCTImageHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -335,7 +335,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTLinkingHeaders (0.72.9): + - React-Core/RCTLinkingHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -349,7 +349,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTNetworkHeaders (0.72.9): + - React-Core/RCTNetworkHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -363,7 +363,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTSettingsHeaders (0.72.9): + - React-Core/RCTSettingsHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -377,7 +377,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTTextHeaders (0.72.9): + - React-Core/RCTTextHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -391,7 +391,7 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTVibrationHeaders (0.72.9): + - React-Core/RCTVibrationHeaders (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -405,11 +405,11 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-Core/RCTWebSocket (0.72.9): + - React-Core/RCTWebSocket (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-Core/Default (= 0.72.9) + - React-Core/Default (= 0.72.7) - React-cxxreact - React-hermes - React-jsi @@ -419,57 +419,57 @@ PODS: - React-utils - SocketRocket (= 0.6.1) - Yoga - - React-CoreModules (0.72.9): + - React-CoreModules (0.72.7): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.72.9) - - React-Codegen (= 0.72.9) - - React-Core/CoreModulesHeaders (= 0.72.9) - - React-jsi (= 0.72.9) + - RCTTypeSafety (= 0.72.7) + - React-Codegen (= 0.72.7) + - React-Core/CoreModulesHeaders (= 0.72.7) + - React-jsi (= 0.72.7) - React-RCTBlob - - React-RCTImage (= 0.72.9) - - ReactCommon/turbomodule/core (= 0.72.9) + - React-RCTImage (= 0.72.7) + - ReactCommon/turbomodule/core (= 0.72.7) - SocketRocket (= 0.6.1) - - React-cxxreact (0.72.9): + - React-cxxreact (0.72.7): - boost (= 1.76.0) - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-callinvoker (= 0.72.9) - - React-debug (= 0.72.9) - - React-jsi (= 0.72.9) - - React-jsinspector (= 0.72.9) - - React-logger (= 0.72.9) - - React-perflogger (= 0.72.9) - - React-runtimeexecutor (= 0.72.9) - - React-debug (0.72.9) - - React-hermes (0.72.9): + - React-callinvoker (= 0.72.7) + - React-debug (= 0.72.7) + - React-jsi (= 0.72.7) + - React-jsinspector (= 0.72.7) + - React-logger (= 0.72.7) + - React-perflogger (= 0.72.7) + - React-runtimeexecutor (= 0.72.7) + - React-debug (0.72.7) + - React-hermes (0.72.7): - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - RCT-Folly/Futures (= 2021.07.22.00) - - React-cxxreact (= 0.72.9) + - React-cxxreact (= 0.72.7) - React-jsi - - React-jsiexecutor (= 0.72.9) - - React-jsinspector (= 0.72.9) - - React-perflogger (= 0.72.9) - - React-jsi (0.72.9): + - React-jsiexecutor (= 0.72.7) + - React-jsinspector (= 0.72.7) + - React-perflogger (= 0.72.7) + - React-jsi (0.72.7): - boost (= 1.76.0) - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-jsiexecutor (0.72.9): + - React-jsiexecutor (0.72.7): - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-cxxreact (= 0.72.9) - - React-jsi (= 0.72.9) - - React-perflogger (= 0.72.9) - - React-jsinspector (0.72.9) - - React-logger (0.72.9): + - React-cxxreact (= 0.72.7) + - React-jsi (= 0.72.7) + - React-perflogger (= 0.72.7) + - React-jsinspector (0.72.7) + - React-logger (0.72.7): - glog - react-native-aes (2.0.0): - React-Core @@ -477,6 +477,9 @@ PODS: - react-native-config/App (= 1.5.1) - react-native-config/App (1.5.1): - React-Core + - react-native-contacts (8.0.7): + - RCT-Folly (= 2021.07.22.00) + - React-Core - react-native-date-picker (5.0.7): - React-Core - react-native-document-picker (9.1.0): @@ -526,7 +529,7 @@ PODS: - react-native-webview (13.8.1): - RCT-Folly (= 2021.07.22.00) - React-Core - - React-NativeModulesApple (0.72.9): + - React-NativeModulesApple (0.72.7): - hermes-engine - React-callinvoker - React-Core @@ -535,17 +538,17 @@ PODS: - React-runtimeexecutor - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - React-perflogger (0.72.9) - - React-RCTActionSheet (0.72.9): - - React-Core/RCTActionSheetHeaders (= 0.72.9) - - React-RCTAnimation (0.72.9): + - React-perflogger (0.72.7) + - React-RCTActionSheet (0.72.7): + - React-Core/RCTActionSheetHeaders (= 0.72.7) + - React-RCTAnimation (0.72.7): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.72.9) - - React-Codegen (= 0.72.9) - - React-Core/RCTAnimationHeaders (= 0.72.9) - - React-jsi (= 0.72.9) - - ReactCommon/turbomodule/core (= 0.72.9) - - React-RCTAppDelegate (0.72.9): + - RCTTypeSafety (= 0.72.7) + - React-Codegen (= 0.72.7) + - React-Core/RCTAnimationHeaders (= 0.72.7) + - React-jsi (= 0.72.7) + - ReactCommon/turbomodule/core (= 0.72.7) + - React-RCTAppDelegate (0.72.7): - RCT-Folly - RCTRequired - RCTTypeSafety @@ -557,54 +560,54 @@ PODS: - React-RCTNetwork - React-runtimescheduler - ReactCommon/turbomodule/core - - React-RCTBlob (0.72.9): + - React-RCTBlob (0.72.7): - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-Codegen (= 0.72.9) - - React-Core/RCTBlobHeaders (= 0.72.9) - - React-Core/RCTWebSocket (= 0.72.9) - - React-jsi (= 0.72.9) - - React-RCTNetwork (= 0.72.9) - - ReactCommon/turbomodule/core (= 0.72.9) - - React-RCTImage (0.72.9): + - React-Codegen (= 0.72.7) + - React-Core/RCTBlobHeaders (= 0.72.7) + - React-Core/RCTWebSocket (= 0.72.7) + - React-jsi (= 0.72.7) + - React-RCTNetwork (= 0.72.7) + - ReactCommon/turbomodule/core (= 0.72.7) + - React-RCTImage (0.72.7): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.72.9) - - React-Codegen (= 0.72.9) - - React-Core/RCTImageHeaders (= 0.72.9) - - React-jsi (= 0.72.9) - - React-RCTNetwork (= 0.72.9) - - ReactCommon/turbomodule/core (= 0.72.9) - - React-RCTLinking (0.72.9): - - React-Codegen (= 0.72.9) - - React-Core/RCTLinkingHeaders (= 0.72.9) - - React-jsi (= 0.72.9) - - ReactCommon/turbomodule/core (= 0.72.9) - - React-RCTNetwork (0.72.9): + - RCTTypeSafety (= 0.72.7) + - React-Codegen (= 0.72.7) + - React-Core/RCTImageHeaders (= 0.72.7) + - React-jsi (= 0.72.7) + - React-RCTNetwork (= 0.72.7) + - ReactCommon/turbomodule/core (= 0.72.7) + - React-RCTLinking (0.72.7): + - React-Codegen (= 0.72.7) + - React-Core/RCTLinkingHeaders (= 0.72.7) + - React-jsi (= 0.72.7) + - ReactCommon/turbomodule/core (= 0.72.7) + - React-RCTNetwork (0.72.7): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.72.9) - - React-Codegen (= 0.72.9) - - React-Core/RCTNetworkHeaders (= 0.72.9) - - React-jsi (= 0.72.9) - - ReactCommon/turbomodule/core (= 0.72.9) - - React-RCTSettings (0.72.9): + - RCTTypeSafety (= 0.72.7) + - React-Codegen (= 0.72.7) + - React-Core/RCTNetworkHeaders (= 0.72.7) + - React-jsi (= 0.72.7) + - ReactCommon/turbomodule/core (= 0.72.7) + - React-RCTSettings (0.72.7): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.72.9) - - React-Codegen (= 0.72.9) - - React-Core/RCTSettingsHeaders (= 0.72.9) - - React-jsi (= 0.72.9) - - ReactCommon/turbomodule/core (= 0.72.9) - - React-RCTText (0.72.9): - - React-Core/RCTTextHeaders (= 0.72.9) - - React-RCTVibration (0.72.9): + - RCTTypeSafety (= 0.72.7) + - React-Codegen (= 0.72.7) + - React-Core/RCTSettingsHeaders (= 0.72.7) + - React-jsi (= 0.72.7) + - ReactCommon/turbomodule/core (= 0.72.7) + - React-RCTText (0.72.7): + - React-Core/RCTTextHeaders (= 0.72.7) + - React-RCTVibration (0.72.7): - RCT-Folly (= 2021.07.22.00) - - React-Codegen (= 0.72.9) - - React-Core/RCTVibrationHeaders (= 0.72.9) - - React-jsi (= 0.72.9) - - ReactCommon/turbomodule/core (= 0.72.9) - - React-rncore (0.72.9) - - React-runtimeexecutor (0.72.9): - - React-jsi (= 0.72.9) - - React-runtimescheduler (0.72.9): + - React-Codegen (= 0.72.7) + - React-Core/RCTVibrationHeaders (= 0.72.7) + - React-jsi (= 0.72.7) + - ReactCommon/turbomodule/core (= 0.72.7) + - React-rncore (0.72.7) + - React-runtimeexecutor (0.72.7): + - React-jsi (= 0.72.7) + - React-runtimescheduler (0.72.7): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) @@ -612,30 +615,30 @@ PODS: - React-debug - React-jsi - React-runtimeexecutor - - React-utils (0.72.9): + - React-utils (0.72.7): - glog - RCT-Folly (= 2021.07.22.00) - React-debug - - ReactCommon/turbomodule/bridging (0.72.9): + - ReactCommon/turbomodule/bridging (0.72.7): - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-callinvoker (= 0.72.9) - - React-cxxreact (= 0.72.9) - - React-jsi (= 0.72.9) - - React-logger (= 0.72.9) - - React-perflogger (= 0.72.9) - - ReactCommon/turbomodule/core (0.72.9): + - React-callinvoker (= 0.72.7) + - React-cxxreact (= 0.72.7) + - React-jsi (= 0.72.7) + - React-logger (= 0.72.7) + - React-perflogger (= 0.72.7) + - ReactCommon/turbomodule/core (0.72.7): - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-callinvoker (= 0.72.9) - - React-cxxreact (= 0.72.9) - - React-jsi (= 0.72.9) - - React-logger (= 0.72.9) - - React-perflogger (= 0.72.9) + - React-callinvoker (= 0.72.7) + - React-cxxreact (= 0.72.7) + - React-jsi (= 0.72.7) + - React-logger (= 0.72.7) + - React-perflogger (= 0.72.7) - RNBootSplash (4.7.5): - React-Core - RNCAsyncStorage (1.21.0): @@ -714,10 +717,9 @@ PODS: - RCT-Folly (= 2021.07.22.00) - React-Core - ReactCommon/turbomodule/core - - RNScreens (3.34.0): + - RNScreens (3.29.0): - RCT-Folly (= 2021.07.22.00) - React-Core - - React-RCTImage - RNSecureRandom (1.0.1): - React - RNShare (10.2.1): @@ -767,6 +769,7 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - react-native-aes (from `../node_modules/react-native-aes-crypto`) - react-native-config (from `../node_modules/react-native-config`) + - react-native-contacts (from `../node_modules/react-native-contacts`) - react-native-date-picker (from `../node_modules/react-native-date-picker`) - react-native-document-picker (from `../node_modules/react-native-document-picker`) - react-native-fingerprint-scanner (from `../node_modules/react-native-fingerprint-scanner`) @@ -888,6 +891,7 @@ EXTERNAL SOURCES: :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" hermes-engine: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :tag: hermes-2023-08-07-RNv0.72.4-813b2def12bc9df02654b3e3653ae4a68d0572e0 RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTRequired: @@ -922,6 +926,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-aes-crypto" react-native-config: :path: "../node_modules/react-native-config" + react-native-contacts: + :path: "../node_modules/react-native-contacts" react-native-date-picker: :path: "../node_modules/react-native-date-picker" react-native-document-picker: @@ -1062,13 +1068,13 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f boost: 7dcd2de282d72e344012f7d6564d024930a6a440 - breez_sdk_liquid: c1ca8548830cd519e7443e9358b2ea97b4cc4461 + breez_sdk_liquid: 481504dda7faf1e6a66208fa9b451e307079113e breez_sdk_liquidFFI: 1091fe9e2d677e7c3b0296848037338b419d0405 BreezSDKLiquid: ebab2b6ffdbf45986b193624c0b37d2a5eb65b24 - BVLinearGradient: 880f91a7854faff2df62518f0281afb1c60d49a3 + BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 - FBLazyVector: dc178b8748748c036ef9493a5d59d6d1f91a36ce - FBReactNativeSpec: d0aaae78e93c89dc2d691d8052a4d2aeb1b461ee + FBLazyVector: 5fbbff1d7734827299274638deb8ba3024f6c597 + FBReactNativeSpec: 638095fe8a01506634d77b260ef8a322019ac671 Firebase: 735108d2d0b67827cd929bfe8254983907c4479f FirebaseABTesting: 01b54808f64fda9d96adc2653bc393e88e21f0da FirebaseAnalytics: 2979c21ead00c520feff30db97ebadd669442f2a @@ -1091,101 +1097,102 @@ SPEC CHECKSUMS: GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GT3Captcha-iOS: aeb6fed2e8594099821430a89208679e5a55b740 - hermes-engine: 9b9bb14184a11b8ceb4131b09abf634880f0f46d + hermes-engine: 9180d43df05c1ed658a87cc733dc3044cf90c00a libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 - RCTRequired: f30c3213569b1dc43659ecc549a6536e1e11139e - RCTTypeSafety: e1ed3137728804fa98bce30b70e3da0b8e23054e - React: 54070abee263d5773486987f1cf3a3616710ed52 - React-callinvoker: 794ea19cc4d8ce25921893141e131b9d6b7d02eb - React-Codegen: 10359be5377b1a652839bcfe7b6b5bd7f73ae9f6 - React-Core: 7e2a9c4594083ecc68b91fc4a3f4d567e8c8b3b3 - React-CoreModules: 87cc386c2200862672b76bb02c4574b4b1d11b3c - React-cxxreact: 1100498800597e812f0ce4ec365f4ea47ac39719 - React-debug: 4dca41301a67ab2916b2c99bef60344a7b653ac5 - React-hermes: b871a77ba1c427ca00f075759dc0cc9670484c94 - React-jsi: 1f8d073a00264c6a701c4b7b4f4ef9946f9b2455 - React-jsiexecutor: 5a169b1dd1abad06bed40ab7e1aca883c657d865 - React-jsinspector: 54205b269da20c51417e0fc02c4cde9f29a4bf1a - React-logger: f42d2f2bc4cbb5d19d7c0ce84b8741b1e54e88c8 - react-native-aes: c75c46aa744bef7c2415fdf7f5b2dcb75ca4364d - react-native-config: 86038147314e2e6d10ea9972022aa171e6b1d4d8 - react-native-date-picker: 06a4d96ab525a163c7a90bccd68833d136b0bb13 - react-native-document-picker: b4f4a23b73f864ce17965b284c0757648993805b - react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe - react-native-geetest-module: ed6a20774a7975640b79a2f639327c674a488cb5 - react-native-get-random-values: 384787fd76976f5aec9465aff6fa9e9129af1e74 - react-native-html-to-pdf: 4c5c6e26819fe202971061594058877aa9b25265 - react-native-image-picker: 5e076db26cd81660cfb6db5bcf517cfa12054d45 - react-native-in-app-review: db8bb167a5f238e7ceca5c242d6b36ce8c4404a4 - react-native-maps: 084fccedd6785bd41e85a13a26e8e6252a45b594 - react-native-nfc-manager: 2a87d561c4fa832e6597a5f2f7c197808c019ab8 - react-native-pager-view: e26d35e382d86950c936f8917e3beb9188115ccc - react-native-quick-crypto: b859e7bc40b1fdd0d9f4b0a1475304fd3e2e216c - react-native-randombytes: 421f1c7d48c0af8dbcd471b0324393ebf8fe7846 - react-native-safe-area-context: 0ee144a6170530ccc37a0fd9388e28d06f516a89 - react-native-secure-key-store: 910e6df6bc33cb790aba6ee24bc7818df1fe5898 - react-native-slider: cc89964e1432fa31aa9db7a0fa9b21e26b5d5152 - react-native-video: c26780b224543c62d5e1b2a7244a5cd1b50e8253 - react-native-view-shot: 6b7ed61d77d88580fed10954d45fad0eb2d47688 - react-native-webview: bdc091de8cf7f8397653e30182efcd9f772e03b3 - React-NativeModulesApple: 9f72feb8a04020b32417f768a7e1e40eec91fef4 - React-perflogger: cb433f318c6667060fc1f62e26eb58d6eb30a627 - React-RCTActionSheet: 0af3f8ac067e8a1dde902810b7ad169d0a0ec31e - React-RCTAnimation: 453a88e76ba6cb49819686acd8b21ce4d9ee4232 - React-RCTAppDelegate: b9fb07959f227ddd2c458c42ed5ceacbd1e1e367 - React-RCTBlob: fa513d56cdc2b7ad84a7758afc4863c1edd6a8b1 - React-RCTImage: 8e059fbdfab18b86127424dc3742532aab960760 - React-RCTLinking: 05ae2aa525b21a7f1c5069c14330700f470efd97 - React-RCTNetwork: 7ed9d99d028c53e9a23e318f65937f499ba8a6fd - React-RCTSettings: 8b12ebf04d4baa0e259017fcef6cf7abd7d8ac51 - React-RCTText: a062ade9ff1591c46bcb6c5055fd4f96c154b8aa - React-RCTVibration: 87c490b6f01746ab8f9b4e555f514cc030c06731 - React-rncore: 140bc11b316da7003bf039844aef39e1c242d7ad - React-runtimeexecutor: 226ebef5f625878d3028b196cbecbbdeb6f208e4 - React-runtimescheduler: a7b1442e155c6f131d8bdfaac47abdc303f50788 - React-utils: a3ffbc321572ee91911d7bc30965abe9aa4e16af - ReactCommon: 180205f326d59f52e12fa724f5278fcf8fb6afc3 - RNBootSplash: 85f6b879c080e958afdb4c62ee04497b05fd7552 - RNCAsyncStorage: 618d03a5f52fbccb3d7010076bc54712844c18ef - RNCClipboard: 60fed4b71560d7bfe40e9d35dea9762b024da86d - RNDateTimePicker: 65e1d202799460b286ff5e741d8baf54695e8abd - RNDeviceInfo: db5c64a060e66e5db3102d041ebe3ef307a85120 - RNFBAnalytics: 8d1705b9076df1ed0b72a165d78c303e8569807b - RNFBApp: fa5825b36d8362ce9b993cac8bf070d116848640 - RNFBAppCheck: f98d1bd525efe4e22f28c6070796434c9baf6e9a - RNFBCrashlytics: 14dcfb073e7d9f41189400128e203d5314a8c606 - RNFBMessaging: 32a107c0463048f1c03df06ab235bf56e976299a - RNFBPerf: a3f48919b73a6355113735a463f048a772fc79a4 - RNFBRemoteConfig: f2d4de1a288ede64e777f3229c7746ed4127ccb3 - RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592 - RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 - RNGestureHandler: c0d04458598fcb26052494ae23dda8f8f5162b13 - RNImageCropPicker: 14fe1c29298fb4018f3186f455c475ab107da332 - RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364 - RNKeychain: a65256b6ca6ba6976132cc4124b238a5b13b3d9c - RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 - RNNotifee: 8e2d3df3f0e9ce8f5d1fe4c967431138190b6175 - RNPermissions: 294531ede5a64d1528463e88fffef05675842042 - RNQrGenerator: 1676221c08bfabec978242989c733810dad20959 - RNRate: ef3bcff84f39bb1d1e41c5593d3eea4aab2bd73a - RNReactNativeHapticFeedback: ec56a5f81c3941206fd85625fa669ffc7b4545f9 - RNReanimated: fdbaa9c964bbab7fac50c90862b6cc5f041679b9 - RNScreens: 80369e822c4f123c3f076c9ea4141991c17770f9 - RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef - RNShare: 0fad69ae2d71de9d1f7b9a43acf876886a6cb99c - RNSVG: ba3e7232f45e34b7b47e74472386cf4e1a676d0a - RNVectorIcons: 64e6a523ac30a3241efa9baf1ffbcc5e76ff747a + RCT-Folly: 8dc08ca5a393b48b1c523ab6220dfdcc0fe000ad + RCTRequired: 83bca1c184feb4d2e51c72c8369b83d641443f95 + RCTTypeSafety: 13c4a87a16d7db6cd66006ce9759f073402ef85b + React: e67aa9f99957c7611c392b5e49355d877d6525e2 + React-callinvoker: 2790c09d964c2e5404b5410cde91b152e3746b7b + React-Codegen: 89173b1974099c3082e50c83e9d04113ede45792 + React-Core: 27990a32ca0cfc04872600440f618365b7c35433 + React-CoreModules: 2a1850a46d60b901cceef4e64bcf5bf6a0130206 + React-cxxreact: 03d370d58a083a1c8b5a69b9095c1ac9f57b2f94 + React-debug: 4accb2b9dc09b575206d2c42f4082990a52ae436 + React-hermes: 0a9e25fbf4dbcd8ca89de9a89a0cce2fce45989f + React-jsi: 0c473d4292f9a10469b3755767bf28d0b35fbeb6 + React-jsiexecutor: 00fdf7bd0e99ab878109ce1b51cb6212d76683e4 + React-jsinspector: 8baadae51f01d867c3921213a25ab78ab4fbcd91 + React-logger: 61efd44da84482aabbbbb478a49b893c7c912f99 + react-native-aes: bed3ca6c47c5a5ebd5bac683efdf737c874f6d3f + react-native-config: 136f9755ccc991cc6438053a44363259ad4c7813 + react-native-contacts: 2be02d0d63726f388cac53413044a7299391365f + react-native-date-picker: 585252087d4820b4cd8f2cf80068f6e8f5b72413 + react-native-document-picker: 74f5ca4179532f9ff205275990af514d1f2e22d8 + react-native-fingerprint-scanner: 91bf6825709dd7bd3abc4588a4772eb097a2b2d8 + react-native-geetest-module: cecd5dfca2c7f815a8e724c11137b35c92e900d3 + react-native-get-random-values: ce0b8796c99e2b85e3202bd500b1ef286a17a02e + react-native-html-to-pdf: 7a49e6c58ac5221bcc093027b195f4b214f27a9d + react-native-image-picker: 7a3502135a13fc56d406f5213b7346de6bc5f38b + react-native-in-app-review: b3d1eed3d1596ebf6539804778272c4c65e4a400 + react-native-maps: 2173cbaddcef764af9a8ed56883b7672d6fc8337 + react-native-nfc-manager: ab799bdeecbb12b199bccdb0065cbb4d3271c1e4 + react-native-pager-view: 8f36f88437684bf5ea86f9172a91c266d99b975f + react-native-quick-crypto: 1daacdde8771548da81d783a1778aba55a7bbf8c + react-native-randombytes: 3c8f3e89d12487fd03a2f966c288d495415fc116 + react-native-safe-area-context: bf9d9d58f0f6726d4a6257088044c2595017579d + react-native-secure-key-store: eb45b44bdec3f48e9be5cdfca0f49ddf64892ea6 + react-native-slider: 2ee855f44d8024139690ad4581cec2d51c616456 + react-native-video: 2aad0d963bf3952bd9ebb2f53fab799338e8e202 + react-native-view-shot: d1a701eb0719c6dccbd20b4bb43b1069f304cb70 + react-native-webview: 11105d80264df1a56fbbb0c774311a52bb287388 + React-NativeModulesApple: 2f7a355e9b4c83b9509bf6dd798dc5f63ab8bc7d + React-perflogger: 31ea61077185eb1428baf60c0db6e2886f141a5a + React-RCTActionSheet: 392090a3abc8992eb269ef0eaa561750588fc39d + React-RCTAnimation: 4b3cc6a29474bc0d78c4f04b52ab59bf760e8a9b + React-RCTAppDelegate: b6febbe1109554fee87d3fea1c50cca511429fec + React-RCTBlob: 76113160e3cdc0f678795823c1a7c9d69b2db099 + React-RCTImage: 8a5d339d614a90a183fc1b8b6a7eb44e2e703943 + React-RCTLinking: b37dfbf646d77c326f9eae094b1fcd575b1c24c7 + React-RCTNetwork: 8bed9b2461c7d8a7d14e63df9b16181c448beebc + React-RCTSettings: 506a5f09a455123a8873801b70aa7b4010b76b01 + React-RCTText: 3c71ecaad8ee010b79632ea2590f86c02f5cce17 + React-RCTVibration: d1b78ca38f61ea4b3e9ebb2ddbd0b5662631d99b + React-rncore: bfc2f6568b6fecbae6f2f774e95c60c3c9e95bf2 + React-runtimeexecutor: 47b0a2d5bbb416db65ef881a6f7bdcfefa0001ab + React-runtimescheduler: d12a963f61390fcd1b957a9c9ebee3c0f775dede + React-utils: 22f94a6e85b1323ffb1b9a747a1c03c5e6eaead6 + ReactCommon: ef602e9cfb8940ad7c08aa4cdc228d802e194e5c + RNBootSplash: 21095c4567847829470786b03b6892c5efed5299 + RNCAsyncStorage: a03b770a50541a761447cea9c24536047832124d + RNCClipboard: 4abb037e8fe3b98a952564c9e0474f91c492df6d + RNDateTimePicker: 47b54bf36a41c29d75ac62a05af1b38a9a721631 + RNDeviceInfo: addb9b427c2822a2d8e94c87a136a224e0af738c + RNFBAnalytics: 81e00e4209b0a6268c2a8b262d7e451493bda824 + RNFBApp: 2b2bb0f17eb6732e2e90d9c57bfde443cd7fc681 + RNFBAppCheck: 6e2df9110387283d00ff126d3903c9f79987d1c8 + RNFBCrashlytics: 266758adee95705af20f106c767e19588a5de665 + RNFBMessaging: 4627e84e9e363953357dd122543e4223c49e6bc1 + RNFBPerf: 594a4c7bb12fb68e920e101192539da748973da8 + RNFBRemoteConfig: 4842e7c1b0bb8d2f9c2acc3b811e6395eddfe550 + RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251 + RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 + RNGestureHandler: 627182485becfd74f122c83f93cce2be20c2e8c8 + RNImageCropPicker: 874e26cbf0ce9d06a11002cbadf29c8b7f2f5565 + RNInAppBrowser: 6d3eb68d471b9834335c664704719b8be1bfdb20 + RNKeychain: df33ae4d27df06622fc14190b790ba8749f6be76 + RNLocalize: 8bf466de4c92d4721b254aabe1ff0a1456e7b9f4 + RNNotifee: 8768d065bf1e2f9f8f347b4bd79147431c7eacd6 + RNPermissions: 963fdb9cf67420e1eaf915940f523b340aa0e2b8 + RNQrGenerator: 60eab4f7c9e3f09db78029636fe356dca5cb585f + RNRate: 7641919330e0d6688ad885a985b4bd697ed7d14c + RNReactNativeHapticFeedback: a6fb5b7a981683bf58af43e3fb827d4b7ed87f83 + RNReanimated: 49a1e0d191bdaefe1b394eb258bc52a42bcf704c + RNScreens: a425ae50ad66d024a6e936121bf5c9fbe6a5cdc6 + RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc + RNShare: 694e19d7f74ac4c04de3a8af0649e9ccc03bd8b1 + RNSVG: 6d5ed33b6635ed6d6ecb50744dcf127580c39ed5 + RNVectorIcons: 2bb1ff267624f4e79188d65908c959fd284c5003 SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 - VisionCamera: 1910a51e4c6f6b049650086d343090f267b4c260 - Yoga: eddf2bbe4a896454c248a8f23b4355891eb720a6 + VisionCamera: 29095ffe0a146b6254c3db34636d10298b169f36 + Yoga: 4c3aa327e4a6a23eeacd71f61c81df1bcdf677d5 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 -PODFILE CHECKSUM: fed83b93e96c4c2f3d9c69483ced921bac08ce31 +PODFILE CHECKSUM: 50930cce21f222e3be3732b10aa64a17e84e029c COCOAPODS: 1.16.2 diff --git a/package.json b/package.json index bb8726aa8..5928097ba 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "react-native-base64": "^0.2.1", "react-native-bootsplash": "^4.6.0", "react-native-config": "^1.5.1", + "react-native-contacts": "^8.0.7", "react-native-countdown-circle-timer": "^3.2.1", "react-native-country-picker-modal": "^2.0.0", "react-native-crypto": "^2.2.0", diff --git a/yarn.lock b/yarn.lock index b7dc058bf..608beafed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20796,6 +20796,11 @@ react-native-config@^1.5.1: resolved "https://registry.yarnpkg.com/react-native-config/-/react-native-config-1.5.1.tgz#73c94f511493e9b7ff9350cdf351d203a1b05acc" integrity sha512-g1xNgt1tV95FCX+iWz6YJonxXkQX0GdD3fB8xQtR1GUBEqweB9zMROW77gi2TygmYmUkBI7LU4pES+zcTyK4HA== +react-native-contacts@^8.0.7: + version "8.0.7" + resolved "https://registry.yarnpkg.com/react-native-contacts/-/react-native-contacts-8.0.7.tgz#486fcc1cc267a2c5bceb51f46263a3e10dbb2650" + integrity sha512-9JyH+3MXZcOD1+Lm+kl1DeQzT24ayZ//LonTSZM2cln66mHgC2MzW1wBQAJ7EL9eu5NSjSav4c4BIWuOsods6Q== + react-native-countdown-circle-timer@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/react-native-countdown-circle-timer/-/react-native-countdown-circle-timer-3.2.1.tgz#077548e943fcf24b47c7e5ce2f12c1dfa35833c2"