migrate to expo contacts class api
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user