[Contacts] Contacts matching flow (#9486)

* add expo-contacts

* add expo-sms

* update copy

* add basic settings screen

* state machine, flow screen

* phone input screen

* otp screen

* tweak spacing

* resend code logic

* add layoutanimationconfig

* check availablility in settings screen

* get contacts

* matches screen

* search, temp setup for matches UI

* add a bunch of number parsing logic with libphonenumber

* cast to looser type

* rename sync to find

FCF (Find Contacts Flow)

* update geolocation hook

* nicer design for settings screen

* add completed state

* up border contrast

* update expo deps

* add pending spinner

* add country whitelist

* add empty state screen

* drop add more functionality

* upload -> import

* fix typo

* fix permission string

* copy updates

* rm envelope icon

* update sms copy

* add inviteinfo component

* woke is back

* [Contacts] NUXes (#9515)

* add a bunch of number parsing logic with libphonenumber

* add banner nux

* add announcement nux

* native only nux

* rm shitty animation

* move isNative check

* [Contacts] Onboarding step (#9489)

* add a bunch of number parsing logic with libphonenumber

* restructure onboarding to better support dynamic screens

* integrate existing flow into onboarding

* add intro step

* lift state up to allow going back freely

* gate onboarding by geo if unsupported country

* add done button to standalone flow

* [Contacts] Add `contact-match` notification type to feed (#9519)

* add a bunch of number parsing logic with libphonenumber

* add contact-joined notif type

* update string, api package

* Update NotificationFeedItem.tsx

* Update NotificationFeedItem.tsx

* Delete SyncContactsFlow.web.tsx

* fix follow back btn for this case

* [Contacts] API integration (#9487)

* api integration for flow

* copy tweak

* tweaks after running it

* wire up status page

* rename toast

* use api lib

* rm temp code

* maybe fix otp error

* clear code on error/resend

* add 1s delay to verify success

* update package versions

* Delete SyncContactsFlow.web.tsx

* try and fix yarn.lock lint

* even woker

* fix uppercase friends

* surfdude feedback

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* rm log

* allow resend on invalid code

* Update GetContacts.tsx

* overwrite country code if possible

* fix Apple's Best Feature

* devenv latest

* disable bounces

* interactive keyboard dismiss

* copy changes

* national format

* allow resending immediately

* move success state down a bit

* Update FindContactsSettings.tsx

* [Contacts] More onboarding changes (#9491)

* integrate existing flow into onboarding

* refreshed onboarding styles, rm stepper

* center content on web

* Add back dismiss button for internal onboarding

* Import sort

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* add matches query to shadow

* add clockwise arrow

* update status design, fix dismiss, fix queries

* add metrics

* add more comments

* Update metrics.ts

* refetch both queries on PTR

* fix shadow state in matches page

* reduce empty space at bottom

* show contact info on matches

* filter out contacts without numbers at an earlier stage

* Error handling 

* get notifs working

* filter out businesses

* rm log

* remove TODO from learn more links

* try and exclude from web bundle

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2025-12-12 20:32:06 +02:00
committed by GitHub
parent d3a6db4594
commit ad7d49ef8f
98 changed files with 4906 additions and 1079 deletions
@@ -0,0 +1,290 @@
import {Alert, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as Contacts from 'expo-contacts'
import {AppBskyContactImportContacts} from '@atproto/api'
import {msg, t, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
import {useAgent} from '#/state/session'
import {atoms as a, ios, tokens, useGutters} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {
contactsWithPhoneNumbersOnly,
filterMatchedNumbers,
getMatchedContacts,
normalizeContactBook,
} from '../contacts'
import {constructFullPhoneNumber} from '../phone-number'
import {type Action, type State} from '../state'
const MAX_UPLOAD_COUNT = 1000
export function GetContacts({
state,
dispatch,
onCancel,
context,
}: {
state: Extract<State, {step: '3: get contacts'}>
dispatch: React.ActionDispatch<[Action]>
onCancel: () => void
context: 'Onboarding' | 'Standalone'
}) {
const {_} = useLingui()
const agent = useAgent()
const insets = useSafeAreaInsets()
const gutters = useGutters([0, 'wide'])
const queryClient = useQueryClient()
const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({
mutationFn: async (contacts: Contacts.ExistingContact[]) => {
const {phoneNumbers, indexToContactId} = normalizeContactBook(
contacts,
state.phoneCountryCode,
constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
)
if (phoneNumbers.length > 0) {
const res = await agent.app.bsky.contact.importContacts({
token: state.token,
contacts: phoneNumbers.slice(0, MAX_UPLOAD_COUNT),
})
return {
matches: res.data.matchesAndContactIndexes,
indexToContactId,
}
} else {
return {
matches: [],
indexToContactId,
}
}
},
onSuccess: (result, contacts) => {
if (context === 'Onboarding') {
logger.metric('onboarding:contacts:contactsShared', {})
}
if (result.matches.length > 0) {
logger.metric('contacts:import:success', {
contactCount: contacts.length,
matchCount: result.matches.length,
entryPoint: context,
})
} else {
logger.metric('contacts:import:failure', {
reason: 'noValidNumbers',
entryPoint: context,
})
}
dispatch({
type: 'SYNC_CONTACTS_SUCCESS',
payload: {
matches: getMatchedContacts(
contacts,
result.matches,
result.indexToContactId,
),
contacts: filterMatchedNumbers(
contacts,
result.matches,
result.indexToContactId,
),
},
})
queryClient.invalidateQueries({
queryKey: findContactsStatusQueryKey,
})
},
onError: err => {
logger.metric('contacts:import:failure', {
reason: isNetworkError(err) ? 'networkError' : 'unknown',
entryPoint: context,
})
if (isNetworkError(err)) {
Toast.show(
_(
msg`There was a problem with your internet connection, please try again`,
),
{type: 'error'},
)
} else if (
err instanceof AppBskyContactImportContacts.TooManyContactsError
) {
Toast.show(
_(
msg`Too many contacts - you've exceeded the number of contacts you can import to find your friends`,
),
{type: 'error'},
)
} else if (
err instanceof AppBskyContactImportContacts.InvalidTokenError
) {
Toast.show(
_(
msg`Could not upload contacts. You need to re-verify your phone number to proceed`,
),
{type: 'error'},
)
} else {
logger.error('Error uploading contacts', {safeMessage: err})
Toast.show(_(msg`Could not upload contacts. ${cleanError(err)}`), {
type: 'error',
})
}
},
})
const {mutate: getContacts, isPending: isGetContactsPending} = useMutation({
mutationFn: async () => {
let permissions = await Contacts.getPermissionsAsync()
if (!permissions.granted && permissions.canAskAgain) {
permissions = await Contacts.requestPermissionsAsync()
}
logger.metric('contacts:permission:request', {
status: permissions.granted ? 'granted' : 'denied',
accessLevelIOS: ios(permissions.accessPrivileges),
})
if (!permissions.granted) {
throw new PermissionDeniedError()
}
const contacts = await Contacts.getContactsAsync({
fields: [
Contacts.Fields.FirstName,
Contacts.Fields.LastName,
Contacts.Fields.PhoneNumbers,
Contacts.Fields.Image,
],
})
return contactsWithPhoneNumbersOnly(contacts.data)
},
onSuccess: contacts => {
dispatch({
type: 'GET_CONTACTS_SUCCESS',
payload: {contacts},
})
uploadContacts(contacts)
},
onError: err => {
if (err instanceof PermissionDeniedError) {
showPermissionDeniedAlert()
} else {
logger.error('Error getting contacts', {safeMessage: err})
}
},
})
const isPending = isUploadPending || isGetContactsPending
const style = [a.text_md, a.leading_snug, a.mt_md]
return (
<View style={[a.h_full]}>
<Layout.Content
contentContainerStyle={[gutters, a.flex_1, a.pt_xl]}
bounces={false}>
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>Share your contacts to find friends</Trans>
</Text>
<Text style={style}>
<Trans>
Bluesky helps friends find each other by creating an encoded digital
fingerprint, called a "hash," and then looking for matching hashes.
</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We never store plain phone numbers</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We delete hashes after matches are made</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We only suggest follows if both people consent</Trans>
</Text>
<Text style={style}>
&bull; <Trans>You can always opt out and delete your data</Trans>
</Text>
<Text style={[style, a.mt_lg]}>
<Trans>
We apply the highest privacy standards, and never share or sell your
contact information.
</Trans>
</Text>
</Layout.Content>
<View
style={[
gutters,
a.pt_xs,
{paddingBottom: Math.max(insets.bottom, tokens.space.xl)},
a.gap_md,
]}>
<Text style={[a.text_sm, a.pb_xs]}>
<Trans>
I consent to Bluesky using my contacts for mutual friend discovery
and to retain hashed data for matching until I opt out.
</Trans>
</Text>
<Button
label={_(msg`Find my friends`)}
size="large"
color="primary"
onPress={() => getContacts()}
disabled={isPending}>
{isUploadPending ? (
<>
<ButtonText>
<Trans>Finding friends...</Trans>
</ButtonText>
<ButtonIcon icon={Loader} />
</>
) : (
<ButtonText>
<Trans>Find my friends</Trans>
</ButtonText>
)}
</Button>
<Button
label={_(msg`Cancel`)}
size="large"
color="secondary"
onPress={onCancel}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
)
}
class PermissionDeniedError extends Error {
constructor() {
super('Permission denied')
}
}
function showPermissionDeniedAlert() {
Alert.alert(
t`You've denied access to your contacts`,
t`You'll need to go to the System Settings for Bluesky and give permission if you want to use this feature.`,
[
{
text: t`OK`,
style: 'default',
},
],
)
}
@@ -0,0 +1,326 @@
import {useState} from 'react'
import {Keyboard, View} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyContactStartPhoneVerification} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {
type CountryCode,
getDefaultCountry,
} from '#/lib/international-telephone-codes'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
import {
android,
atoms as a,
platform,
tokens,
useGutters,
useTheme,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {InternationalPhoneCodeSelect} from '#/components/InternationalPhoneCodeSelect'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useGeolocation} from '#/geolocation'
import {isFindContactsFeatureEnabled} from '../country-allowlist'
import {
constructFullPhoneNumber,
getCountryCodeFromPastedNumber,
processPhoneNumber,
} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state'
export function PhoneInput({
state,
dispatch,
context,
onSkip,
}: {
state: Extract<State, {step: '1: phone input'}>
dispatch: React.ActionDispatch<[Action]>
context: 'Onboarding' | 'Standalone'
onSkip: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const agent = useAgent()
const location = useGeolocation()
const [countryCode, setCountryCode] = useState(
() => state.phoneCountryCode ?? getDefaultCountry(location),
)
const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '')
const gutters = useGutters([0, 'wide'])
const insets = useSafeAreaInsets()
// for API/generic errors
const [error, setError] = useState('')
// for issues with parsing the number
const [formatError, setFormatError] = useState('')
const {mutate: submit, isPending} = useMutation({
mutationFn: async ({
phoneCountryCode,
phoneNumber,
}: {
phoneCountryCode: CountryCode
phoneNumber: string
}) => {
// sends a onetime code to the user's phone number
await agent.app.bsky.contact.startPhoneVerification({
phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber),
})
},
onSuccess: (_data, {phoneCountryCode, phoneNumber}) => {
dispatch({
type: 'SUBMIT_PHONE_NUMBER',
payload: {phoneCountryCode, phoneNumber},
})
logger.metric('contacts:phone:phoneEntered', {entryPoint: context})
},
onMutate: () => {
Keyboard.dismiss()
setError('')
setFormatError('')
},
onError: err => {
if (isNetworkError(err)) {
setError(
_(
msg`A network error occurred. Please check your internet connection`,
),
)
} else if (
err instanceof
AppBskyContactStartPhoneVerification.RateLimitExceededError
) {
setError(_(msg`Rate limit exceeded. Please try again later.`))
} else if (
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
) {
setError(
_(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
)
} else {
logger.error('Verify phone number failed', {safeMessage: err})
setError(_(msg`An error occurred. ${cleanError(err)}`))
}
},
})
const isFeatureEnabled = isFindContactsFeatureEnabled(countryCode)
const onSubmitNumber = () => {
if (!isFeatureEnabled) return
if (!phoneNumber) return
const result = processPhoneNumber(phoneNumber, countryCode)
if (result.valid) {
setPhoneNumber(result.formatted)
setCountryCode(result.countryCode)
if (!isFindContactsFeatureEnabled(result.countryCode)) return
submit({
phoneCountryCode: result.countryCode,
phoneNumber: result.formatted,
})
} else {
setFormatError(result.reason ?? _(msg`Invalid phone number`))
}
}
const paddingBottom = Math.max(insets.bottom, tokens.space.xl)
const onPressBack = useOnPressBackButton()
return (
<View style={[a.h_full]}>
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton onPress={onPressBack} />
<Layout.Header.Content />
{context === 'Onboarding' ? (
<Button
size="small"
color="secondary"
variant="ghost"
label={_(msg`Skip contact sharing and continue to the app`)}
onPress={onSkip}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
<Layout.Content
contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
keyboardShouldPersistTaps="handled">
{context === 'Onboarding' && <OnboardingPosition />}
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>Verify phone number</Trans>
</Text>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
a.mt_sm,
]}>
<Trans>
We need to verify your number before we can look for your friends. A
verification code will be sent to this number.
</Trans>
</Text>
<View style={[a.mt_2xl]}>
<TextField.LabelText>
<Trans>Phone number</Trans>
</TextField.LabelText>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<View>
<InternationalPhoneCodeSelect
value={countryCode}
onChange={value => setCountryCode(value)}
/>
</View>
<View style={[a.flex_1]}>
<TextField.Root isInvalid={!!formatError || !isFeatureEnabled}>
<TextField.Input
label={_(msg`Phone number`)}
value={phoneNumber}
onChangeText={text => {
if (formatError) setFormatError('')
if (Math.abs(text.length - phoneNumber.length) > 1) {
// possibly pasted/autocompleted? auto-switch
// country code if possible
const result = getCountryCodeFromPastedNumber(text)
if (result) {
setCountryCode(result.countryCode)
setPhoneNumber(result.rest)
return
}
}
setPhoneNumber(text)
}}
placeholder={null}
keyboardType={platform({
ios: 'number-pad',
android: 'phone-pad',
})}
autoComplete="tel"
returnKeyType={android('next')}
onSubmitEditing={onSubmitNumber}
/>
</TextField.Root>
</View>
</View>
</View>
{!isFeatureEnabled && (
<ErrorText>
<Trans>
Support for this feature in your country has not been enabled yet!
Please check back later.
</Trans>
</ErrorText>
)}
{error && <ErrorText>{error}</ErrorText>}
{formatError && <ErrorText>{formatError}</ErrorText>}
<View style={[a.mt_auto, a.py_xl]}>
<LegalDisclaimer />
</View>
</Layout.Content>
<KeyboardAvoidingView
behavior="padding"
keyboardVerticalOffset={insets.top - paddingBottom + tokens.space.xl}>
<View style={[gutters, {paddingBottom}]}>
<Button
disabled={!phoneNumber || isPending}
label={_(msg`Send code`)}
size="large"
color="primary"
onPress={onSubmitNumber}>
<ButtonText>
<Trans>Send code</Trans>
</ButtonText>
{isPending && <ButtonIcon icon={Loader} />}
</Button>
</View>
</KeyboardAvoidingView>
</View>
)
}
function LegalDisclaimer() {
const t = useTheme()
const {_} = useLingui()
const style = [a.text_xs, t.atoms.text_contrast_medium, a.leading_snug]
return (
<View style={[a.gap_xs]}>
<Text style={[style, a.font_medium]}>
<Trans>How we use your number:</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Sent to our phone number verification provider Plivo</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Deleted by Plivo after verification</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Stored as part of a secure code for matching with others</Trans>
</Text>
<Text style={[style, a.mt_xs]}>
<Trans>
By continuing, you consent to this use. You may change your mind any
time by visiting settings.{' '}
<InlineLinkText
to="#"
label={_(
msg({
message: `Learn more about importing contacts`,
context: `english-only-resource`,
}),
)}
style={[a.text_xs, a.leading_snug]}>
<Trans context="english-only-resource">Learn more</Trans>
</InlineLinkText>
</Trans>
</Text>
</View>
)
}
function ErrorText({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<Text
style={[
a.text_md,
{color: t.palette.negative_500},
a.leading_snug,
a.mt_md,
]}>
{children}
</Text>
)
}
@@ -0,0 +1,386 @@
import {useEffect, useMemo, useState} from 'react'
import {Text as NestedText, View} from 'react-native'
import {
AppBskyContactStartPhoneVerification,
AppBskyContactVerifyPhone,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {clamp} from '#/lib/numbers'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {OTPInput} from '../components/OTPInput'
import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state'
export function VerifyNumber({
state,
dispatch,
context,
onSkip,
}: {
state: Extract<State, {step: '2: verify number'}>
dispatch: React.ActionDispatch<[Action]>
context: 'Onboarding' | 'Standalone'
onSkip: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const gutters = useGutters([0, 'wide'])
const [otpCode, setOtpCode] = useState('')
const [error, setError] = useState<{
retryable: boolean
isResendError: boolean
message: string
} | null>(null)
const [prevOtpCode, setPrevOtpCode] = useState(otpCode)
if (otpCode !== prevOtpCode) {
setPrevOtpCode(otpCode)
setError(null)
}
const phone = useMemo(
() => constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
[state.phoneCountryCode, state.phoneNumber],
)
const prettyNumber = useMemo(() => prettyPhoneNumber(phone), [phone])
const {
mutate: verifyNumber,
isPending,
isSuccess,
} = useMutation({
mutationFn: async (code: string) => {
const res = await agent.app.bsky.contact.verifyPhone({code, phone})
return res.data.token
},
onSuccess: async token => {
// let the success state show for a moment
setTimeout(() => {
dispatch({
type: 'VERIFY_PHONE_NUMBER_SUCCESS',
payload: {
token,
},
})
}, 1000)
logger.metric('contacts:phone:phoneVerified', {entryPoint: context})
},
onMutate: () => setError(null),
onError: err => {
setOtpCode('')
if (isNetworkError(err)) {
setError({
retryable: true,
isResendError: false,
message: _(
msg`A network error occurred. Please check your internet connection.`,
),
})
} else if (err instanceof AppBskyContactVerifyPhone.InvalidCodeError) {
setError({
retryable: true,
isResendError: true,
message: _(msg`This code is invalid. Resend to get a new code.`),
})
} else if (err instanceof AppBskyContactVerifyPhone.InvalidPhoneError) {
setError({
retryable: false,
isResendError: false,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
} else if (
err instanceof AppBskyContactVerifyPhone.RateLimitExceededError
) {
setError({
retryable: true,
isResendError: false,
message: _(
msg`Too many attempts. Please wait a few minutes and try again.`,
),
})
} else {
logger.error('Verify phone number failed', {safeMessage: err})
setError({
retryable: true,
isResendError: false,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
}
},
})
const {mutate: resendCode, isPending: isResendingCode} = useMutation({
mutationFn: async () => {
await agent.app.bsky.contact.startPhoneVerification({phone: phone})
},
onSuccess: () => {
dispatch({type: 'RESEND_VERIFICATION_CODE'})
Toast.show(_(msg`A new code has been sent`))
},
onMutate: () => {
setOtpCode('')
setError(null)
},
onError: err => {
if (isNetworkError(err)) {
setError({
retryable: true,
isResendError: true,
message: _(
msg`A network error occurred. Please check your internet connection.`,
),
})
} else if (
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
) {
setError({
retryable: false,
isResendError: true,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
} else if (
err instanceof
AppBskyContactStartPhoneVerification.RateLimitExceededError
) {
setError({
retryable: true,
isResendError: true,
message: _(
msg`Too many codes sent. Please wait a few minutes and try again.`,
),
})
} else {
logger.error('Resend failed', {safeMessage: err})
setError({
retryable: true,
isResendError: true,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
}
},
})
const onPressBack = useOnPressBackButton()
return (
<View style={[a.h_full]}>
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton onPress={onPressBack} />
<Layout.Header.Content />
{context === 'Onboarding' ? (
<Button
size="small"
color="secondary"
variant="ghost"
label={_(msg`Skip contact sharing and continue to the app`)}
onPress={onSkip}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
<Layout.Content
contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
keyboardShouldPersistTaps="always">
{context === 'Onboarding' && <OnboardingPosition />}
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>Verify phone number</Trans>
</Text>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
a.mt_sm,
]}>
<Trans>Enter the 6-digit code sent to {prettyNumber}</Trans>
</Text>
<View style={[a.mt_2xl]}>
<OTPInput
label={_(
msg`Enter 6-digit code that was sent to your phone number`,
)}
value={otpCode}
onChange={setOtpCode}
onComplete={code => verifyNumber(code)}
/>
</View>
<View style={[a.mt_sm]}>
<OTPStatus
error={error}
isPending={isPending}
isResendingCode={isResendingCode}
isSuccess={isSuccess}
onResend={() => resendCode()}
onRetry={() => verifyNumber(otpCode)}
lastCodeSentAt={state.lastSentAt}
/>
</View>
</Layout.Content>
</View>
)
}
/**
* Horrible component that takes all the state above and figures out what messages
* and buttons to display.
*/
function OTPStatus({
error,
isPending,
isResendingCode,
isSuccess,
onResend,
onRetry,
lastCodeSentAt,
}: {
error: {
retryable: boolean
isResendError: boolean
message: string
} | null
isPending: boolean
isResendingCode: boolean
isSuccess: boolean
onResend: () => void
onRetry: () => void
lastCodeSentAt: Date | null
}) {
const {_} = useLingui()
const t = useTheme()
const [time, setTime] = useState(Date.now())
useEffect(() => {
const interval = setInterval(() => {
setTime(Date.now())
}, 1000)
return () => clearInterval(interval)
}, [])
const timeUntilCanResend = Math.max(
0,
30000 - (time - (lastCodeSentAt?.getTime() ?? 0)),
)
const isWaiting = timeUntilCanResend > 0
let Icon: React.ComponentType<SVGIconProps> | null = null
let text = ''
let textColor = t.atoms.text_contrast_medium.color
let showResendButton = false
let showRetryButton = false
if (isSuccess) {
Icon = CircleCheckIcon
text = _(msg`Phone number verified`)
textColor = t.palette.positive_500
} else if (isPending) {
text = _(msg`Verifying...`)
} else if (error) {
Icon = WarningIcon
text = error.message
textColor = t.palette.negative_500
if (error.retryable) {
if (error.isResendError) {
showResendButton = true
} else {
showRetryButton = true
}
}
} else {
showResendButton = true
}
return (
<View style={[a.w_full, a.align_center]}>
{text && (
<View
style={[
a.gap_xs,
a.flex_row,
a.align_center,
(isSuccess || isPending) && a.mt_lg,
]}>
{Icon && <Icon size="xs" style={{color: textColor}} />}
<Text
style={[
{color: textColor},
a.text_sm,
a.leading_snug,
a.text_center,
]}>
{text}
</Text>
</View>
)}
{showRetryButton && (
<Button
size="small"
color="secondary_inverted"
label={_(msg`Retry`)}
onPress={onRetry}
style={[a.mt_2xl]}>
<ButtonIcon icon={RetryIcon} />
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
</Button>
)}
{showResendButton && (
<Button
size="large"
color="secondary"
variant="ghost"
label={_(msg`Resend code`)}
disabled={isResendingCode || isWaiting}
onPress={onResend}
style={[a.mt_2xl]}>
{isResendingCode && <ButtonIcon icon={Loader} />}
<ButtonText>
{isWaiting ? (
<Trans>
Resend code in{' '}
<NestedText style={{fontVariant: ['tabular-nums']}}>
00:
{String(
clamp(Math.round(timeUntilCanResend / 1000), 0, 30),
).padStart(2, '0')}
</NestedText>
</Trans>
) : (
<Trans>Resend code</Trans>
)}
</ButtonText>
</Button>
)}
</View>
)
}
@@ -0,0 +1,683 @@
import {useCallback, useMemo, useRef, useState} from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as SMS from 'expo-sms'
import {type ModerationOpts} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {
updateProfileShadow,
useProfileShadow,
} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
optimisticRemoveMatch,
useMatchesPassthroughQuery,
} from '#/state/queries/find-contacts'
import {useAgent, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {SearchInput} from '#/components/forms/SearchInput'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {MagnifyingGlassX_Stroke2_Corner0_Rounded_Large as SearchFailedIcon} from '#/components/icons/MagnifyingGlass'
import {PersonX_Stroke2_Corner0_Rounded_Large as PersonXIcon} from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {InviteInfo} from '../components/InviteInfo'
import {type Action, type Contact, type Match, type State} from '../state'
type Item =
| {
type: 'matches header'
count: number
}
| {
type: 'match'
match: Match
}
| {
type: 'contacts header'
}
| {
type: 'contact'
contact: Contact
}
| {
type: 'no matches header'
}
| {
type: 'search empty state'
query: string
}
| {
type: 'totally empty state'
}
export function ViewMatches({
state,
dispatch,
context,
onNext,
}: {
state: Extract<State, {step: '4: view matches'}>
dispatch: React.ActionDispatch<[Action]>
context: 'Onboarding' | 'Standalone'
onNext: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const gutter = useGutters([0, 'wide'])
const moderationOpts = useModerationOpts()
const queryClient = useQueryClient()
const agent = useAgent()
const insets = useSafeAreaInsets()
const listRef = useRef<ListMethods>(null)
const [search, setSearch] = useState('')
const {
state: searchFocused,
onIn: onFocus,
onOut: onBlur,
} = useInteractionState()
// HACK: Although we already have the match data, we need to pass it through
// a query to get it into the shadow state
const allMatches = useMatchesPassthroughQuery(state.matches)
const matches = allMatches.filter(
match => !state.dismissedMatches.includes(match.profile.did),
)
console.log(matches)
const followableDids = matches.map(match => match.profile.did)
const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0)
const cumulativeFollowCount = useRef(0)
const onFollow = useCallback(() => {
logger.metric('contacts:matches:follow', {entryPoint: context})
cumulativeFollowCount.current += 1
}, [context])
const {mutate: followAll, isPending: isFollowingAll} = useMutation({
mutationFn: async () => {
for (const did of followableDids) {
updateProfileShadow(queryClient, did, {
followingUri: 'pending',
})
}
const uris = await wait(500, bulkWriteFollows(agent, followableDids))
for (const did of followableDids) {
const uri = uris.get(did)
updateProfileShadow(queryClient, did, {
followingUri: uri,
})
}
return followableDids
},
onMutate: () =>
logger.metric('contacts:matches:followAll', {
followCount: followableDids.length,
entryPoint: context,
}),
onSuccess: () => {
setDidFollowAll(true)
Toast.show(_(msg`All friends followed!`), {type: 'success'})
cumulativeFollowCount.current += followableDids.length
},
onError: _err => {
Toast.show(_(msg`Failed to follow all your friends, please try again`), {
type: 'error',
})
for (const did of followableDids) {
updateProfileShadow(queryClient, did, {
followingUri: undefined,
})
}
},
})
const items = useMemo(() => {
const all: Item[] = []
if (searchFocused || search.length > 0) {
for (const match of matches) {
if (
search.length === 0 ||
(match.profile.displayName ?? '')
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase()) ||
match.profile.handle
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())
) {
all.push({type: 'match', match})
}
}
for (const contact of state.contacts) {
if (
search.length === 0 ||
[contact.firstName, contact.lastName]
.filter(Boolean)
.join(' ')
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())
) {
all.push({type: 'contact', contact})
}
}
if (all.length === 0) {
all.push({type: 'search empty state', query: search})
}
} else {
if (matches.length > 0) {
all.push({type: 'matches header', count: matches.length})
for (const match of matches) {
all.push({type: 'match', match})
}
if (state.contacts.length > 0) {
all.push({type: 'contacts header'})
}
} else if (state.contacts.length > 0) {
all.push({type: 'no matches header'})
}
for (const contact of state.contacts) {
all.push({type: 'contact', contact})
}
if (all.length === 0) {
all.push({type: 'totally empty state'})
}
}
return all
}, [matches, state.contacts, search, searchFocused])
const {mutate: dismissMatch} = useMutation({
mutationFn: async (did: string) => {
await agent.app.bsky.contact.dismissMatch({subject: did})
},
onMutate: did => {
logger.metric('contacts:matches:dismiss', {entryPoint: context})
dispatch({type: 'DISMISS_MATCH', payload: {did}})
},
onSuccess: (_res, did) => {
// for the other screen
optimisticRemoveMatch(queryClient, did)
},
onError: (err, did) => {
dispatch({type: 'DISMISS_MATCH_FAILED', payload: {did}})
if (isNetworkError(err)) {
Toast.show(
_(
msg`Failed to hide suggestion, please check your internet connection`,
),
{type: 'error'},
)
} else {
logger.error('Dismissing match failed', {safeMessage: err})
Toast.show(
_(msg`An error occurred while hiding suggestion. ${cleanError(err)}`),
{type: 'error'},
)
}
},
})
const renderItem = ({item}: {item: Item}) => {
switch (item.type) {
case 'match':
return (
<MatchItem
profile={item.match.profile}
contact={item.match.contact}
moderationOpts={moderationOpts}
onRemoveSuggestion={dismissMatch}
onFollow={onFollow}
/>
)
case 'contact':
return <ContactItem contact={item.contact} context={context} />
case 'matches header':
return (
<Header
titleText={
<Plural
value={item.count}
one="# friend found!"
other="# friends found!"
/>
}>
{item.count > 1 && (
<Button
label={_(msg`Follow all`)}
size="small"
color="primary_subtle"
onPress={() => followAll()}
disabled={isFollowingAll || didFollowAll}>
<ButtonIcon
icon={
isFollowingAll
? Loader
: !didFollowAll
? PlusIcon
: CheckIcon
}
/>
<ButtonText>
<Trans>Follow all</Trans>
</ButtonText>
</Button>
)}
</Header>
)
case 'contacts header':
return (
<Header
titleText={
<Trans>
Invite friends{' '}
<InviteInfo iconStyle={t.atoms.text} iconOffset={1} />
</Trans>
}
hasContentAbove
/>
)
case 'no matches header':
return (
<Header
titleText={_(msg`You got here first`)}
largeTitle
subtitleText={
<Trans>
Bluesky is more fun with friends. Do you want to invite some of
yours?{' '}
<InviteInfo
iconStyle={t.atoms.text_contrast_medium}
iconOffset={2}
/>
</Trans>
}
/>
)
case 'search empty state':
return <SearchEmptyState query={item.query} />
case 'totally empty state':
return <TotallyEmptyState />
}
}
const isSearchEmpty = items?.[0]?.type === 'search empty state'
const isTotallyEmpty = items?.[0]?.type === 'totally empty state'
const isEmpty = isSearchEmpty || isTotallyEmpty
return (
<View style={[a.h_full]}>
{context === 'Standalone' && (
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
)}
{!isTotallyEmpty && (
<View
style={[
gutter,
a.mb_md,
context === 'Onboarding' && [a.mt_sm, a.gap_sm],
]}>
{context === 'Onboarding' && <OnboardingPosition />}
<SearchInput
placeholder={_(msg`Search contacts`)}
value={search}
onFocus={() => {
onFocus()
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onBlur={() => {
onBlur()
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onChangeText={text => {
setSearch(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onClearText={() => setSearch('')}
/>
</View>
)}
<List
ref={listRef}
data={items}
renderItem={renderItem}
ListFooterComponent={!isEmpty ? <ListFooter height={20} /> : null}
keyExtractor={keyExtractor}
keyboardDismissMode="interactive"
automaticallyAdjustKeyboardInsets
/>
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border_t,
a.align_center,
a.align_stretch,
gutter,
a.pt_md,
{paddingBottom: insets.bottom + tokens.space.md},
]}>
<Button
label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)}
onPress={() => {
if (context === 'Onboarding') {
logger.metric('onboarding:contacts:nextPressed', {
matchCount: allMatches.length,
followCount: cumulativeFollowCount.current,
dismissedMatchCount: state.dismissedMatches.length,
})
}
onNext()
}}
size="large"
color="primary">
<ButtonText>
{context === 'Onboarding' ? (
<Trans>Next</Trans>
) : (
<Trans>Done</Trans>
)}
</ButtonText>
</Button>
</View>
</View>
)
}
function keyExtractor(item: Item) {
switch (item.type) {
case 'contact':
return item.contact.id
case 'match':
return item.match.profile.did
default:
return item.type
}
}
function MatchItem({
profile,
contact,
moderationOpts,
onRemoveSuggestion,
onFollow,
}: {
profile: bsky.profile.AnyProfileView
contact?: Contact
moderationOpts?: ModerationOpts
onRemoveSuggestion: (did: string) => void
onFollow: () => void
}) {
const gutter = useGutters([0, 'wide'])
const t = useTheme()
const {_} = useLingui()
const shadow = useProfileShadow(profile)
const contactName = useMemo(() => {
if (!contact) return null
const name = contact.firstName ?? contact.lastName ?? contact.name
if (name) return _(msg`Your contact ${name}`)
const phone =
contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0]
if (phone?.number) return phone.number
return null
}, [contact, _])
if (!moderationOpts) return null
return (
<View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={48}
/>
<View style={[a.flex_1]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
textStyle={[a.leading_tight]}
/>
<ProfileCard.Handle
profile={profile}
textStyle={[contactName && a.text_xs]}
/>
{contactName && (
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium, a.text_xs]}
numberOfLines={1}>
{contactName}
</Text>
)}
</View>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="FindContacts"
onFollow={onFollow}
/>
{!shadow.viewer?.following && (
<Button
color="secondary"
variant="ghost"
label={_(msg`Remove suggestion`)}
onPress={() => onRemoveSuggestion(profile.did)}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
hitSlop={8}>
<ButtonIcon icon={XIcon} />
</Button>
)}
</ProfileCard.Header>
</View>
)
}
function ContactItem({
contact,
context,
}: {
contact: Contact
context: 'Onboarding' | 'Standalone'
}) {
const gutter = useGutters([0, 'wide'])
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const name = contact.firstName ?? contact.lastName ?? contact.name
const phone =
contact.phoneNumbers?.find(phone => phone.isPrimary) ??
contact.phoneNumbers?.[0]
const phoneNumber = phone?.number
return (
<View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
{contact.image ? (
<UserAvatar size={40} avatar={contact.image.uri} type="user" />
) : (
<View
style={[
{width: 40, height: 40},
a.rounded_full,
a.justify_center,
a.align_center,
t.atoms.bg_contrast_400,
]}>
<Text
style={[
a.text_lg,
a.font_semi_bold,
{color: t.palette.contrast_0},
]}>
{name?.[0]?.toLocaleUpperCase()}
</Text>
</View>
)}
<Text
style={[
a.flex_1,
a.text_md,
a.font_medium,
!name && [t.atoms.text_contrast_medium, a.italic],
]}
numberOfLines={2}>
{name ?? <Trans>No name</Trans>}
</Text>
{phoneNumber && currentAccount && (
<Button
label={_(msg`Invite ${name} to join Bluesky`)}
color="secondary"
size="small"
onPress={async () => {
logger.metric('contacts:matches:invite', {
entryPoint: context,
})
try {
await SMS.sendSMSAsync(
[phoneNumber],
_(
msg`I'm on Bluesky as ${currentAccount.handle} - come find me! https://bsky.app/download`,
),
)
} catch (err) {
Toast.show(_(msg`Failed to launch SMS app`), {type: 'error'})
logger.error('Could not launch SMS', {safeMessage: err})
}
}}>
<ButtonText>
<Trans>Invite</Trans>
</ButtonText>
</Button>
)}
</ProfileCard.Header>
</View>
)
}
function Header({
titleText,
largeTitle,
subtitleText,
children,
hasContentAbove,
}: {
titleText: React.ReactNode
largeTitle?: boolean
subtitleText?: React.ReactNode
children?: React.ReactNode
hasContentAbove?: boolean
}) {
const gutter = useGutters([0, 'wide'])
const t = useTheme()
return (
<View
style={[
gutter,
a.pb_md,
a.gap_sm,
hasContentAbove
? [a.pt_4xl, a.border_t, t.atoms.border_contrast_low]
: a.pt_md,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<Text style={[largeTitle ? a.text_3xl : a.text_xl, a.font_bold]}>
{titleText}
</Text>
{children}
</View>
{subtitleText && (
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
{subtitleText}
</Text>
)}
</View>
)
}
function SearchEmptyState({query}: {query: string}) {
const t = useTheme()
return (
<View
style={[
a.flex_1,
a.flex_col,
a.align_center,
a.justify_center,
a.gap_lg,
a.pt_5xl,
a.px_5xl,
]}>
<SearchFailedIcon width={64} style={[t.atoms.text_contrast_low]} />
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
a.text_center,
]}>
<Trans>No contacts with the name {query} found</Trans>
</Text>
</View>
)
}
function TotallyEmptyState() {
const t = useTheme()
return (
<View
style={[
a.flex_1,
a.flex_col,
a.align_center,
a.justify_center,
a.gap_lg,
{paddingTop: 140},
a.px_5xl,
]}>
<PersonXIcon width={64} style={[t.atoms.text_contrast_low]} />
<Text style={[a.text_xl, a.font_bold, a.leading_snug, a.text_center]}>
<Trans>No contacts found</Trans>
</Text>
</View>
)
}