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 = {
* Backup your cash wallet and increase transaction limits.
*/
upgradeDesc: string
+ /**
+ * Invite Friends
+ */
+ inviteTitle: string
+ /**
+ * Get rewards for inviting friends to Flash
+ */
+ inviteDesc: string
/**
* Change to your local currency
*/
@@ -2729,6 +2737,49 @@ type RootTranslation = {
* Hide Bitcoin account
*/
hideBtcAccount: string
+ /**
+ * Invite a friend
+ */
+ inviteFriend: string
+ }
+ InviteFriend: {
+ /**
+ * Invitation
+ */
+ invitation: string
+ /**
+ * Invite a friend to Flash!
+ */
+ title: string
+ /**
+ * 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: string
+ /**
+ * Enter phone number
+ */
+ phoneNumber: string
+ /**
+ * Enter email address
+ */
+ email: string
+ /**
+ * Invite
+ */
+ invite: string
+ /**
+ * OR
+ */
+ or: string
+ /**
+ * Invitation has been sent to {value}
+ * @param {string} value
+ */
+ invitationSuccessTitle: RequiredParams<'value'>
+ /**
+ * Done
+ */
+ 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"