[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:
@@ -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}>
|
||||
• <Trans>We never store plain phone numbers</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>We delete hashes after matches are made</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>We only suggest follows if both people consent</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <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}>
|
||||
•{' '}
|
||||
<Trans>Sent to our phone number verification provider Plivo</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>Deleted by Plivo after verification</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
•{' '}
|
||||
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
•{' '}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user