import {useContext} from 'react' import {Alert, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import * as Contacts from 'expo-contacts' import type AtpAgent from '@atproto/api' import { type AppBskyActorProfile, AppBskyContactImportContacts, type Un$Typed, } from '@atproto/api' import {msg, t, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useMutation, useQueryClient} from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {findContactsStatusQueryKey} from '#/state/queries/find-contacts' import {useAgent} from '#/state/session' import { Context as OnboardingContext, type OnboardingAction, type OnboardingState, } from '#/screens/Onboarding/state' 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 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 maybeOnboardingContext = useContext(OnboardingContext) const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({ mutationFn: async (contacts: Contacts.ExistingContact[]) => { /** * `importContacts` triggers a notification for the people you match with, * however we prevent notifications coming from users without profiles. * If you're using this as the onboarding flow, we need to create a profile * record before this. * * When you finish onboarding, we'll upsert again - bit wasteful but fine. */ if (context === 'Onboarding' && maybeOnboardingContext) { try { await createProfileRecord(agent, maybeOnboardingContext) } catch (error) { logger.debug('Error creating profile record:', {safeMessage: error}) } } 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 ( Share your contacts to find friends Bluesky helps friends find each other by creating an encoded digital fingerprint, called a "hash", and then looking for matching hashes. • We never keep plain phone numbers • We delete hashes after matches are made • We only suggest follows if both people consent • You can always opt out and delete your data We apply the highest privacy standards, and never share or sell your contact information. I consent to Bluesky using my contacts for mutual friend discovery and to retain hashed data for matching until I opt out. ) } 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', }, ], ) } /** * Copied from `#/screens/Onboarding/StepFinished/index.tsx` */ async function createProfileRecord( agent: AtpAgent, onboardingContext: { state: OnboardingState dispatch: React.Dispatch }, ) { const profileStepResults = onboardingContext.state.profileStepResults const {imageUri, imageMime} = profileStepResults const blobPromise = imageUri && imageMime ? uploadBlob(agent, imageUri, imageMime) : undefined await agent.upsertProfile(async existing => { const next: Un$Typed = existing ?? {} if (blobPromise) { const res = await blobPromise if (res.data.blob) { next.avatar = res.data.blob } } next.displayName = '' next.createdAt = new Date().toISOString() return next }) }