From 3b9506f8e41f2a2a74298833a61ccf5258c9f02e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 4 Sep 2026 12:41:20 +0300 Subject: [PATCH] migrate to expo contacts class api --- src/components/contacts/contacts.ts | 42 ++++----- .../contacts/device-contacts.test.ts | 90 +++++++++++++++++++ src/components/contacts/device-contacts.ts | 18 ++++ .../contacts/screens/GetContacts.tsx | 14 +-- .../contacts/screens/ViewMatches.tsx | 18 ++-- src/components/contacts/state.ts | 4 +- .../StepFindContactsIntro/index.tsx | 20 +---- src/screens/Settings/FindContactsSettings.tsx | 40 +++------ 8 files changed, 150 insertions(+), 96 deletions(-) create mode 100644 src/components/contacts/device-contacts.test.ts create mode 100644 src/components/contacts/device-contacts.ts diff --git a/src/components/contacts/contacts.ts b/src/components/contacts/contacts.ts index e30736a8ea..dc4543ae3b 100644 --- a/src/components/contacts/contacts.ts +++ b/src/components/contacts/contacts.ts @@ -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 + indexToContactId: Map } { const phoneNumbers: string[] = [] - const indexToContactId = new Map() + const indexToContactId = new Map() 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, + mapping: Map, ) { - const filteredIds = new Set() + const filteredIds = new Set() 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, + mapping: Map, ): Array { const contactsById = new Map(contacts.map(c => [c.id, c])) diff --git a/src/components/contacts/device-contacts.test.ts b/src/components/contacts/device-contacts.test.ts new file mode 100644 index 0000000000..71073aba12 --- /dev/null +++ b/src/components/contacts/device-contacts.test.ts @@ -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 { + return { + id: 'contact-1', + fullName: null, + givenName: null, + familyName: null, + company: null, + phones: [{id: 'phone-1', number: '+1 415 555 2671'}], + image: null, + ...overrides, + } +} diff --git a/src/components/contacts/device-contacts.ts b/src/components/contacts/device-contacts.ts new file mode 100644 index 0000000000..fabcd878d2 --- /dev/null +++ b/src/components/contacts/device-contacts.ts @@ -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) +} diff --git a/src/components/contacts/screens/GetContacts.tsx b/src/components/contacts/screens/GetContacts.tsx index 534a9a708b..13704a98e4 100644 --- a/src/components/contacts/screens/GetContacts.tsx +++ b/src/components/contacts/screens/GetContacts.tsx @@ -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({ diff --git a/src/components/contacts/screens/ViewMatches.tsx b/src/components/contacts/screens/ViewMatches.tsx index cba8f7092a..5ad5e642ca 100644 --- a/src/components/contacts/screens/ViewMatches.tsx +++ b/src/components/contacts/screens/ViewMatches.tsx @@ -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 ( {contact.image ? ( - + ) : ( {phoneNumber && currentAccount && (