migrate to expo contacts class api

This commit is contained in:
Samuel Newman
2026-09-04 12:41:20 +03:00
parent f69ad16fed
commit 3b9506f8e4
8 changed files with 150 additions and 96 deletions
+17 -25
View File
@@ -1,18 +1,18 @@
import {type CountryCode} from '#/lib/international-telephone-codes'
import {type app} from '#/lexicons'
import {type DeviceContact} from './device-contacts'
import {normalizePhoneNumber} from './phone-number'
import {type Contact, type Match} from './state'
import {type Match} from './state'
/**
* Filters out contacts that do not have any associated phone numbers,
* as well as businesses
*/
export function contactsWithPhoneNumbersOnly(contacts: Contact[]) {
export function contactsWithPhoneNumbersOnly(contacts: DeviceContact[]) {
return contacts.filter(
contact =>
contact.phoneNumbers &&
contact.phoneNumbers.length > 0 &&
contact.contactType !== 'company',
contact.phones.length > 0 &&
(!contact.company || contact.givenName || contact.familyName),
)
}
@@ -24,31 +24,23 @@ export function contactsWithPhoneNumbersOnly(contacts: Contact[]) {
* I'm making the assumption that most local numbers in someone's phone book will be the same as theirs.
*/
export function normalizeContactBook(
contacts: Contact[],
contacts: DeviceContact[],
countryCode: CountryCode,
ownNumber: string,
): {
phoneNumbers: string[]
indexToContactId: Map<number, Contact['id']>
indexToContactId: Map<number, DeviceContact['id']>
} {
const phoneNumbers: string[] = []
const indexToContactId = new Map<number, Contact['id']>()
const indexToContactId = new Map<number, DeviceContact['id']>()
for (const contact of contacts) {
for (const number of contact.phoneNumbers ?? []) {
let rawNumber: string
if (number.number) {
rawNumber = number.number
} else if (number.digits) {
rawNumber = number.digits
} else {
continue
}
for (const number of contact.phones) {
if (!number.number) continue
const normalized = normalizePhoneNumber(
rawNumber,
number.countryCode,
number.number,
undefined,
countryCode,
)
if (normalized === null) continue
@@ -68,11 +60,11 @@ export function normalizeContactBook(
}
export function filterMatchedNumbers(
contacts: Contact[],
contacts: DeviceContact[],
results: app.bsky.contact.defs.MatchAndContactIndex[],
mapping: Map<number, Contact['id']>,
mapping: Map<number, DeviceContact['id']>,
) {
const filteredIds = new Set<Contact['id']>()
const filteredIds = new Set<DeviceContact['id']>()
for (const result of results) {
const id = mapping.get(result.contactIndex)
@@ -85,9 +77,9 @@ export function filterMatchedNumbers(
}
export function getMatchedContacts(
contacts: Contact[],
contacts: DeviceContact[],
results: app.bsky.contact.defs.MatchAndContactIndex[],
mapping: Map<number, Contact['id']>,
mapping: Map<number, DeviceContact['id']>,
): Array<Match> {
const contactsById = new Map(contacts.map(c => [c.id, c]))
@@ -0,0 +1,90 @@
import {Contact} from 'expo-contacts'
import {describe, expect, it, jest} from '@jest/globals'
import {contactsWithPhoneNumbersOnly, normalizeContactBook} from './contacts'
import {type DeviceContact, getDeviceContacts} from './device-contacts'
jest.mock('expo-contacts', () => ({
Contact: {
getAllDetails: jest.fn(),
},
ContactField: {
FULL_NAME: 'fullName',
GIVEN_NAME: 'givenName',
FAMILY_NAME: 'familyName',
COMPANY: 'company',
PHONES: 'phones',
IMAGE: 'image',
},
}))
describe('getDeviceContacts', () => {
it('gets the contact fields needed for imports using the class API', async () => {
const contact = {
id: 'contact-1',
fullName: 'Alice Example',
givenName: 'Alice',
familyName: 'Example',
company: null,
phones: [{id: 'phone-1', number: '+1 234 567 890'}],
image: 'file:///contact.jpg',
} satisfies DeviceContact
jest.mocked(Contact.getAllDetails).mockResolvedValue([contact])
await expect(getDeviceContacts()).resolves.toEqual([contact])
expect(Contact.getAllDetails).toHaveBeenCalledWith([
'fullName',
'givenName',
'familyName',
'company',
'phones',
'image',
])
})
})
describe('new contact shape', () => {
it('keeps people with phone numbers and filters empty and business contacts', () => {
const person = createContact({
id: 'person',
givenName: 'Alice',
company: 'Bluesky',
})
expect(
contactsWithPhoneNumbersOnly([
createContact({id: 'no-phone', phones: []}),
createContact({
id: 'business',
fullName: 'Business Inc.',
company: 'Business Inc.',
}),
person,
]),
).toEqual([person])
})
it('normalizes phone numbers from the new phones field', () => {
const {phoneNumbers, indexToContactId} = normalizeContactBook(
[createContact({id: 'person'})],
'US',
'+14155550000',
)
expect(phoneNumbers).toEqual(['+14155552671'])
expect(indexToContactId.get(0)).toBe('person')
})
})
function createContact(overrides: Partial<DeviceContact> = {}): DeviceContact {
return {
id: 'contact-1',
fullName: null,
givenName: null,
familyName: null,
company: null,
phones: [{id: 'phone-1', number: '+1 415 555 2671'}],
image: null,
...overrides,
}
}
@@ -0,0 +1,18 @@
import * as Contacts from 'expo-contacts'
const CONTACT_FIELDS = [
Contacts.ContactField.FULL_NAME,
Contacts.ContactField.GIVEN_NAME,
Contacts.ContactField.FAMILY_NAME,
Contacts.ContactField.COMPANY,
Contacts.ContactField.PHONES,
Contacts.ContactField.IMAGE,
] as const
export type DeviceContact = Contacts.PartialContactDetails<
typeof CONTACT_FIELDS
>
export function getDeviceContacts() {
return Contacts.Contact.getAllDetails(CONTACT_FIELDS)
}
@@ -36,6 +36,7 @@ import {
getMatchedContacts,
normalizeContactBook,
} from '../contacts'
import {type DeviceContact, getDeviceContacts} from '../device-contacts'
import {constructFullPhoneNumber} from '../phone-number'
import {type Action, type State} from '../state'
@@ -62,7 +63,7 @@ export function GetContacts({
const maybeOnboardingContext = useContext(OnboardingContext)
const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({
mutationFn: async (contacts: Contacts.ExistingContact[]) => {
mutationFn: async (contacts: DeviceContact[]) => {
/**
* `importContacts` triggers a notification for the people you match with,
* however we prevent notifications coming from users without profiles.
@@ -194,16 +195,7 @@ export function GetContacts({
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)
return contactsWithPhoneNumbersOnly(await getDeviceContacts())
},
onSuccess: contacts => {
dispatch({
@@ -183,7 +183,7 @@ export function ViewMatches({
for (const contact of state.contacts) {
if (
search.length === 0 ||
[contact.firstName, contact.lastName]
[contact.fullName, contact.givenName, contact.familyName]
.filter(Boolean)
.join(' ')
.toLocaleLowerCase()
@@ -458,10 +458,9 @@ function MatchItem({
const contactName = useMemo(() => {
if (!contact) return null
const name = contact.name ?? contact.firstName ?? contact.lastName
const name = contact.fullName ?? contact.givenName ?? contact.familyName
if (name) return _(msg`Your contact ${name}`)
const phone =
contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0]
const phone = contact.phones[0]
if (phone?.number) return phone.number
return null
}, [contact, _])
@@ -530,17 +529,16 @@ function ContactItem({
const ax = useAnalytics()
const {currentAccount} = useSession()
const name = contact.name ?? contact.firstName ?? contact.lastName
const phone =
contact.phoneNumbers?.find(phone => phone.isPrimary) ??
contact.phoneNumbers?.[0]
const name =
contact.fullName ?? contact.givenName ?? contact.familyName ?? undefined
const phone = contact.phones[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" />
<UserAvatar size={40} avatar={contact.image} type="user" />
) : (
<View
style={[
@@ -572,7 +570,7 @@ function ContactItem({
</Text>
{phoneNumber && currentAccount && (
<Button
label={_(msg`Invite ${name} to join Bluesky`)}
label={_(msg`Invite ${name ?? phoneNumber} to join Bluesky`)}
color="secondary"
size="small"
onPress={async () => {
+2 -2
View File
@@ -1,11 +1,11 @@
import {createContext, useContext, useReducer} from 'react'
import {type GestureResponderEvent} from 'react-native'
import {type ExistingContact} from 'expo-contacts'
import {type CountryCode} from '#/lib/international-telephone-codes'
import type * as bsky from '#/types/bsky'
import {type DeviceContact} from './device-contacts'
export type Contact = ExistingContact
export type Contact = DeviceContact
export type Match = {
profile: bsky.profile.AnyProfileView
@@ -1,14 +1,11 @@
import {View} from 'react-native'
import * as Contacts from 'expo-contacts'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useQuery} from '@tanstack/react-query'
import {urls} from '#/lib/constants'
import {useCallOnce} from '#/lib/once'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
import {InlineLinkText} from '#/components/Link'
@@ -30,11 +27,6 @@ export function StepFindContactsIntro() {
ax.metric('onboarding:contacts:presented', {})
})()
const {data: isAvailable, isSuccess} = useQuery({
queryKey: ['contacts-available'],
queryFn: async () => await Contacts.isAvailableAsync(),
})
return (
<View style={[a.w_full, a.gap_sm]}>
<OnboardingPosition />
@@ -60,23 +52,13 @@ export function StepFindContactsIntro() {
</InlineLinkText>
</Trans>
</OnboardingDescriptionText>
{!isAvailable && isSuccess && (
<Admonition type="error">
<Trans>
Contact sync is not available on this device, as the app is unable
to access your contacts.
</Trans>
</Admonition>
)}
<OnboardingControls.Portal>
<View style={[a.gap_md]}>
<Button
onPress={() => dispatch({type: 'next'})}
label={_(msg`Import contacts`)}
size="large"
color="primary"
disabled={!isAvailable}>
color="primary">
<ButtonText>
<Trans>Import contacts</Trans>
</ButtonText>
+11 -29
View File
@@ -1,13 +1,12 @@
import {useCallback, useEffect, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native'
import * as Contacts from 'expo-contacts'
import {type DidString} from '@atproto/syntax'
import {type ModerationOpts} from '@bsky/sdk/moderation'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {useIsFocused} from '@react-navigation/native'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait'
import {HITSLOP_10, urls} from '#/lib/constants'
@@ -33,7 +32,6 @@ import {useAppviewClient, usePdsClient, useSession} from '#/state/session'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {List} from '#/view/com/util/List'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
import {useDialogControl} from '#/components/Dialog'
@@ -116,11 +114,6 @@ function Intro() {
const ax = useAnalytics()
const inviteFriendsControl = useDialogControl()
const {data: isAvailable, isSuccess} = useQuery({
queryKey: ['contacts-available'],
queryFn: async () => await Contacts.isAvailableAsync(),
})
return (
<Layout.Content contentContainerStyle={[gutter, a.gap_lg]}>
<ContactsHeroImage />
@@ -145,27 +138,16 @@ function Intro() {
</InlineLinkText>
</Trans>
</Text>
{isAvailable ? (
<Link
to={{screen: 'FindContactsFlow'}}
label={_(msg`Import contacts`)}
size="large"
color="primary"
style={[a.flex_1, a.justify_center]}>
<ButtonText>
<Trans>Import contacts</Trans>
</ButtonText>
</Link>
) : (
isSuccess && (
<Admonition type="error">
<Trans>
Contact sync is not available on this device, as the app is unable
to access your contacts.
</Trans>
</Admonition>
)
)}
<Link
to={{screen: 'FindContactsFlow'}}
label={_(msg`Import contacts`)}
size="large"
color="primary"
style={[a.flex_1, a.justify_center]}>
<ButtonText>
<Trans>Import contacts</Trans>
</ButtonText>
</Link>
<Button
label={_(msg`Share my profile`)}
size="large"