[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
This commit is contained in:
Samuel Newman
2025-12-10 22:13:25 +02:00
parent 2f06eff36c
commit ff7f07f8c7
17 changed files with 424 additions and 120 deletions
+2
View File
@@ -111,6 +111,8 @@ This is NOT required for app development but if you also want to develop the Blu
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app) - Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
- Launch a Postgres database on port 5432 - Launch a Postgres database on port 5432
- `cd packages/dev-env && pnpm start` - `cd packages/dev-env && pnpm start`
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 yarn start`
Then, when logging in or creating an account, point it to the localhost port of the devserver. Then, when logging in or creating an account, point it to the localhost port of the devserver.
+2 -2
View File
@@ -72,7 +72,7 @@
"icons:optimize": "svgo -f ./assets/icons" "icons:optimize": "svgo -f ./assets/icons"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.18.6", "@atproto/api": "^0.18.7",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6", "@bsky.app/alf": "^0.1.6",
@@ -171,7 +171,7 @@
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lande": "^1.0.10", "lande": "^1.0.10",
"libphonenumber-js": "^1.12.30", "libphonenumber-js": "^1.12.31",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
@@ -1,3 +0,0 @@
export function SyncContactsFlow() {
throw new Error('SyncContactsFlow is not available on web')
}
@@ -27,7 +27,7 @@ export function OTPInput({
onChange: (text: string) => void onChange: (text: string) => void
ref?: React.Ref<TextInput> ref?: React.Ref<TextInput>
numberOfDigits?: number numberOfDigits?: number
onComplete?: () => void onComplete?: (code: string) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -41,7 +41,7 @@ export function OTPInput({
text = text.slice(0, numberOfDigits) text = text.slice(0, numberOfDigits)
onChange(text) onChange(text)
if (text.length === numberOfDigits) { if (text.length === numberOfDigits) {
onComplete?.() onComplete?.(text)
innerRef.current?.blur() innerRef.current?.blur()
} }
} }
@@ -108,11 +108,7 @@ export function OTPInput({
ios: 'one-time-code', ios: 'one-time-code',
})} })}
autoFocus autoFocus
onFocus={() => { onFocus={onFocus}
onChangeText('')
setSelection({start: 0, end: 0})
onFocus()
}}
onBlur={onBlur} onBlur={onBlur}
maxLength={numberOfDigits} maxLength={numberOfDigits}
style={[ style={[
+73
View File
@@ -0,0 +1,73 @@
import {type AppBskyContactDefs} from '@atproto/api'
import {type CountryCode} from '#/lib/international-telephone-codes'
import {normalizePhoneNumber} from './phone-number'
import {type Contact} from './state'
/**
* Takes the raw contact book and returns a plain list of numbers in E.164 format, along
* with a mapping to retrieve the contact ID when we get the results back.
*
* `countryCode` is used as a fallback for local numbers that don't have a country code associated with them.
* 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[],
countryCode: CountryCode,
ownNumber: string,
): {
phoneNumbers: string[]
indexToContactId: Map<number, Contact['id']>
} {
const phoneNumbers: string[] = []
const indexToContactId = new Map<number, Contact['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
}
const normalized = normalizePhoneNumber(
rawNumber,
number.countryCode,
countryCode,
)
if (normalized === null) continue
// skip if it's your own number
if (normalized === ownNumber) continue
phoneNumbers.push(normalized)
indexToContactId.set(phoneNumbers.length - 1, contact.id)
}
}
return {
phoneNumbers,
indexToContactId,
}
}
export function filterMatchedNumbers(
contacts: Contact[],
results: AppBskyContactDefs.MatchAndContactIndex[],
mapping: Map<number, Contact['id']>,
) {
const filteredIds = new Set<Contact['id']>()
for (const result of results) {
const id = mapping.get(result.contactIndex)
if (id !== undefined) {
filteredIds.add(id)
}
}
return contacts.filter(contact => !filteredIds.has(contact.id))
}
+68
View File
@@ -1,5 +1,6 @@
import {t} from '@lingui/macro' import {t} from '@lingui/macro'
import { import {
isSupportedCountry,
ParseError, ParseError,
parsePhoneNumber, parsePhoneNumber,
parsePhoneNumberWithError, parsePhoneNumberWithError,
@@ -61,12 +62,45 @@ export function processPhoneNumber(
} }
} }
/**
* Format a phone number as the international format with the prefix
* removed.
*/
function formatInternationalWithoutCountryCode(phoneNumber: PhoneNumber) { function formatInternationalWithoutCountryCode(phoneNumber: PhoneNumber) {
const intl = phoneNumber.formatInternational() const intl = phoneNumber.formatInternational()
const prefix = '+' + phoneNumber.countryCallingCode const prefix = '+' + phoneNumber.countryCallingCode
return intl.replace(prefix, '').trim() return intl.replace(prefix, '').trim()
} }
/**
* Takes a country code and a prefix-less phone number and constructs a full phone number.
*
* Does not have nice error handling - if you're unsure if the number is valid, use
* `processPhoneNumber` instead
*/
export function constructFullPhoneNumber(
countryCode: CountryCode,
phoneNumber: string,
) {
const result = parsePhoneNumber(phoneNumber, {defaultCountry: countryCode})
if (!result.isValid())
throw new Error('Invalid phone number passed to constructFullPhoneNumber')
return result.format('E.164')
}
/**
* Takes a phone number and applies human-readable formatting. Do not sent to the API - they
* expect E.164 format.
*/
export function prettyPhoneNumber(phoneNumber: string) {
const result = parsePhoneNumber(phoneNumber)
return result.formatInternational()
}
/**
* Attempts to parse a phone number from a string, and returns the country code
* and the rest of the number if possible. If the number is invalid, returns undefined.
*/
export function getCountryCodeFromPastedNumber( export function getCountryCodeFromPastedNumber(
text: string, text: string,
): {countryCode: CountryCode; rest: string} | undefined { ): {countryCode: CountryCode; rest: string} | undefined {
@@ -89,3 +123,37 @@ export function getCountryCodeFromPastedNumber(
return undefined return undefined
} }
} }
/**
* Normalizes a phone number into E.164 format
*/
export function normalizePhoneNumber(
rawNumber: string,
countryCode: string | undefined,
fallbackCountryCode: CountryCode,
): string | null {
try {
const result = parsePhoneNumber(rawNumber, {
defaultCountry:
countryCode && isSupportedCountry(countryCode)
? countryCode
: fallbackCountryCode,
})
if (!result.isValid()) return null
const type = result.getType()
if (
type !== 'MOBILE' &&
type !== 'FIXED_LINE_OR_MOBILE' &&
type !== 'PERSONAL_NUMBER'
) {
return null
}
return result.format('E.164')
} catch (error) {
console.log('Failed to normalize phone number:', error)
return null
}
}
@@ -3,18 +3,24 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as Contacts from 'expo-contacts' import * as Contacts from 'expo-contacts'
import {msg, t, Trans} from '@lingui/macro' import {msg, t, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' 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 {logger} from '#/logger'
import {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
import {useAgent} from '#/state/session'
import {atoms as a, tokens, useGutters} from '#/alf' import {atoms as a, tokens, useGutters} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {filterMatchedNumbers, normalizeContactBook} from '../contacts'
import {constructFullPhoneNumber} from '../phone-number'
import {type Action, type State} from '../state' import {type Action, type State} from '../state'
export function GetContacts({ export function GetContacts({
state,
dispatch, dispatch,
onCancel, onCancel,
}: { }: {
@@ -23,20 +29,58 @@ export function GetContacts({
onCancel: () => void onCancel: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const gutters = useGutters([0, 'wide']) const gutters = useGutters([0, 'wide'])
const queryClient = useQueryClient()
const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({ const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({
mutationFn: async (_contacts: Contacts.ExistingContact[]) => { mutationFn: async (contacts: Contacts.ExistingContact[]) => {
await wait(2e3, () => {}) const {phoneNumbers, indexToContactId} = normalizeContactBook(
contacts,
state.phoneCountryCode,
constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
)
const res = await agent.app.bsky.contact.importContacts({
token: state.token,
contacts: phoneNumbers,
})
return {
matches: res.data.matchesAndContactIndexes,
indexToContactId,
}
}, },
onSuccess: () => { onSuccess: (result, contacts) => {
dispatch({ dispatch({
type: 'SYNC_CONTACTS_SUCCESS', type: 'SYNC_CONTACTS_SUCCESS',
payload: { payload: {
matches: [], matches: result.matches.map(match => match.match),
contacts: filterMatchedNumbers(
contacts,
result.matches,
result.indexToContactId,
),
}, },
}) })
queryClient.invalidateQueries({
queryKey: findContactsStatusQueryKey,
})
},
onError: err => {
if (isNetworkError(err)) {
Toast.show(
_(
msg`There was a problem with your internet connection, please try again`,
),
{type: 'error'},
)
} else {
logger.error('Error uploading contacts', {safeMessage: err})
Toast.show(_(msg`Could not upload contacts. ${cleanError(err)}`), {
type: 'error',
})
}
}, },
}) })
+11 -4
View File
@@ -12,6 +12,7 @@ import {
} from '#/lib/international-telephone-codes' } from '#/lib/international-telephone-codes'
import {cleanError, isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {android, atoms as a, tokens, useGutters, useTheme} from '#/alf' import {android, atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
@@ -23,6 +24,7 @@ import {Text} from '#/components/Typography'
import {useGeolocation} from '#/geolocation' import {useGeolocation} from '#/geolocation'
import {isFindContactsFeatureEnabled} from '../country-whitelist' import {isFindContactsFeatureEnabled} from '../country-whitelist'
import { import {
constructFullPhoneNumber,
getCountryCodeFromPastedNumber, getCountryCodeFromPastedNumber,
processPhoneNumber, processPhoneNumber,
} from '../phone-number' } from '../phone-number'
@@ -41,6 +43,7 @@ export function PhoneInput({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const agent = useAgent()
const location = useGeolocation() const location = useGeolocation()
const [countryCode, setCountryCode] = useState( const [countryCode, setCountryCode] = useState(
() => state.phoneCountryCode ?? getDefaultCountry(location), () => state.phoneCountryCode ?? getDefaultCountry(location),
@@ -54,13 +57,16 @@ export function PhoneInput({
const [formatError, setFormatError] = useState('') const [formatError, setFormatError] = useState('')
const {mutate: submit, isPending} = useMutation({ const {mutate: submit, isPending} = useMutation({
mutationFn: async ({}: { mutationFn: async ({
phoneCountryCode,
phoneNumber,
}: {
phoneCountryCode: CountryCode phoneCountryCode: CountryCode
phoneNumber: string phoneNumber: string
}) => { }) => {
// get otp // sends a onetime code to the user's phone number
await new Promise(resolve => { await agent.app.bsky.contact.startPhoneVerification({
setTimeout(resolve, 500) phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber),
}) })
}, },
onSuccess: (_data, {phoneCountryCode, phoneNumber}) => { onSuccess: (_data, {phoneCountryCode, phoneNumber}) => {
@@ -92,6 +98,7 @@ export function PhoneInput({
const onSubmitNumber = () => { const onSubmitNumber = () => {
if (!isFeatureEnabled) return if (!isFeatureEnabled) return
if (!phoneNumber) return
const result = processPhoneNumber(phoneNumber, countryCode) const result = processPhoneNumber(phoneNumber, countryCode)
if (result.valid) { if (result.valid) {
setPhoneNumber(result.formatted) setPhoneNumber(result.formatted)
@@ -4,10 +4,10 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {getPhoneCodeFromCountryCode} from '#/lib/international-telephone-codes'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
import {cleanError, isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {atoms as a, useGutters, useTheme} from '#/alf' import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
@@ -19,7 +19,9 @@ import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {OTPInput} from '../components/OTPInput' import {OTPInput} from '../components/OTPInput'
import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state' import {type Action, type State, useOnPressBackButton} from '../state'
export function VerifyNumber({ export function VerifyNumber({
state, state,
dispatch, dispatch,
@@ -33,6 +35,7 @@ export function VerifyNumber({
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent()
const gutters = useGutters([0, 'wide']) const gutters = useGutters([0, 'wide'])
const [otpCode, setOtpCode] = useState('') const [otpCode, setOtpCode] = useState('')
@@ -48,25 +51,36 @@ export function VerifyNumber({
setError(null) setError(null)
} }
const phone = useMemo(
() => constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
[state.phoneCountryCode, state.phoneNumber],
)
const prettyNumber = useMemo(() => prettyPhoneNumber(phone), [phone])
const { const {
mutate: verifyNumber, mutate: verifyNumber,
isPending, isPending,
isSuccess, isSuccess,
} = useMutation({ } = useMutation({
mutationFn: async (_code: string) => { mutationFn: async (code: string) => {
await new Promise(resolve => setTimeout(resolve, 500)) const res = await agent.app.bsky.contact.verifyPhone({code, phone})
return 'success' return res.data.token
}, },
onSuccess: async () => { onSuccess: async token => {
dispatch({ // let the success state show for a moment
type: 'VERIFY_PHONE_NUMBER_SUCCESS', setTimeout(() => {
payload: { dispatch({
token: 'example_token', type: 'VERIFY_PHONE_NUMBER_SUCCESS',
}, payload: {
}) token,
},
})
}, 1000)
}, },
onMutate: () => setError(null), onMutate: () => setError(null),
onError: err => { onError: err => {
setOtpCode('')
if (isNetworkError(err)) { if (isNetworkError(err)) {
setError({ setError({
retryable: true, retryable: true,
@@ -95,13 +109,14 @@ export function VerifyNumber({
const {mutate: resendCode, isPending: isResendingCode} = useMutation({ const {mutate: resendCode, isPending: isResendingCode} = useMutation({
mutationFn: async () => { mutationFn: async () => {
await new Promise(resolve => setTimeout(resolve, 2000)) await agent.app.bsky.contact.startPhoneVerification({phone: phone})
}, },
onSuccess: () => { onSuccess: () => {
dispatch({type: 'RESEND_VERIFICATION_CODE'}) dispatch({type: 'RESEND_VERIFICATION_CODE'})
Toast.show(_(msg`Code resent`)) Toast.show(_(msg`A new code has been sent`))
}, },
onMutate: () => { onMutate: () => {
setOtpCode('')
setError(null) setError(null)
}, },
onError: err => { onError: err => {
@@ -116,11 +131,6 @@ export function VerifyNumber({
}, },
}) })
const phoneCode = useMemo(
() => getPhoneCodeFromCountryCode(state.phoneCountryCode),
[state.phoneCountryCode],
)
const onPressBack = useOnPressBackButton() const onPressBack = useOnPressBackButton()
return ( return (
@@ -156,9 +166,7 @@ export function VerifyNumber({
a.leading_snug, a.leading_snug,
a.mt_sm, a.mt_sm,
]}> ]}>
<Trans> <Trans>Enter the 6-digit code sent to {prettyNumber}</Trans>
Enter the 6-digit code sent to {phoneCode} {state.phoneNumber}
</Trans>
</Text> </Text>
<View style={[a.mt_2xl]}> <View style={[a.mt_2xl]}>
<OTPInput <OTPInput
@@ -167,7 +175,7 @@ export function VerifyNumber({
)} )}
value={otpCode} value={otpCode}
onChange={setOtpCode} onChange={setOtpCode}
onComplete={() => verifyNumber(otpCode)} onComplete={code => verifyNumber(code)}
/> />
</View> </View>
<View style={[a.mt_sm]}> <View style={[a.mt_sm]}>
@@ -259,7 +267,7 @@ function OTPStatus({
<View style={[a.w_full, a.align_center]}> <View style={[a.w_full, a.align_center]}>
{text && ( {text && (
<View style={[a.gap_xs, a.flex_row, a.align_center]}> <View style={[a.gap_xs, a.flex_row, a.align_center]}>
{Icon && <Icon size="xs" color={textColor} />} {Icon && <Icon size="xs" style={{color: textColor}} />}
<Text <Text
style={[ style={[
{color: textColor}, {color: textColor},
@@ -15,7 +15,6 @@ import {
useProfileShadow, useProfileShadow,
} from '#/state/cache/profile-shadow' } from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List' import {List, type ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -86,13 +85,6 @@ export function ViewMatches({
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const listRef = useRef<ListMethods>(null) const listRef = useRef<ListMethods>(null)
// TEMP!!!
const {data: profiles} = useProfilesQuery({
handles: ['pfrazee.com', 'internet.bsky.social', 'darrin.bsky.team'],
})
state.matches = profiles?.profiles?.map(profile => ({profile})) ?? []
// state.contacts = []
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const { const {
state: searchFocused, state: searchFocused,
@@ -101,7 +93,7 @@ export function ViewMatches({
} = useInteractionState() } = useInteractionState()
const followableDids = state.matches const followableDids = state.matches
.map(match => match.profile.did) .map(match => match.did)
.filter(did => !state.dismissedMatches.includes(did)) .filter(did => !state.dismissedMatches.includes(did))
const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0) const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0)
@@ -144,20 +136,16 @@ export function ViewMatches({
if (searchFocused || search.length > 0) { if (searchFocused || search.length > 0) {
for (const match of state.matches) { for (const match of state.matches) {
const profile = match.profile if (state.dismissedMatches.includes(match.did)) continue
if (state.dismissedMatches.includes(profile.did)) continue
if ( if (
search.length === 0 || search.length === 0 ||
(profile.displayName ?? '') (match.displayName ?? '')
.toLocaleLowerCase() .toLocaleLowerCase()
.includes(search.toLocaleLowerCase()) || .includes(search.toLocaleLowerCase()) ||
profile.handle match.handle.toLocaleLowerCase().includes(search.toLocaleLowerCase())
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())
) { ) {
all.push({type: 'match', profile}) all.push({type: 'match', profile: match})
} }
} }
@@ -179,14 +167,13 @@ export function ViewMatches({
} }
} else { } else {
const matches = state.matches.filter( const matches = state.matches.filter(
match => !state.dismissedMatches.includes(match.profile.did), match => !state.dismissedMatches.includes(match.did),
) )
if (matches.length > 0) { if (matches.length > 0) {
all.push({type: 'matches header', count: matches.length}) all.push({type: 'matches header', count: matches.length})
for (const match of matches) { for (const match of matches) {
const profile = match.profile all.push({type: 'match', profile: match})
all.push({type: 'match', profile})
} }
if (state.contacts.length > 0) { if (state.contacts.length > 0) {
+9 -9
View File
@@ -7,12 +7,6 @@ import type * as bsky from '#/types/bsky'
export type Contact = ExistingContact export type Contact = ExistingContact
// TODO: replace with lexicon type
export type Match = {
index?: number
profile: bsky.profile.AnyProfileView
}
export type State = export type State =
| { | {
step: '1: phone input' step: '1: phone input'
@@ -27,13 +21,15 @@ export type State =
} }
| { | {
step: '3: get contacts' step: '3: get contacts'
phoneCountryCode: CountryCode
phoneNumber: string
token: string token: string
contacts?: Contact[] contacts?: Contact[]
} }
| { | {
step: '4: view matches' step: '4: view matches'
contacts: Contact[] contacts: Contact[]
matches: Match[] matches: bsky.profile.AnyProfileView[]
// rather than mutating `matches`, we keep track of dismissed matches // rather than mutating `matches`, we keep track of dismissed matches
// so we can roll back optimistic updates // so we can roll back optimistic updates
dismissedMatches: string[] dismissedMatches: string[]
@@ -65,7 +61,9 @@ export type Action =
| { | {
type: 'SYNC_CONTACTS_SUCCESS' type: 'SYNC_CONTACTS_SUCCESS'
payload: { payload: {
matches: Match[] matches: bsky.profile.AnyProfileView[]
// filter out matched contacts
contacts: Contact[]
} }
} }
| { | {
@@ -106,6 +104,8 @@ function reducer(state: State, action: Action): State {
return { return {
step: '3: get contacts', step: '3: get contacts',
token: action.payload.token, token: action.payload.token,
phoneCountryCode: state.phoneCountryCode,
phoneNumber: state.phoneNumber,
} }
} }
case 'BACK': { case 'BACK': {
@@ -127,7 +127,7 @@ function reducer(state: State, action: Action): State {
assertCurrentStep(state, '3: get contacts') assertCurrentStep(state, '3: get contacts')
return { return {
step: '4: view matches', step: '4: view matches',
contacts: state.contacts ?? [], contacts: action.payload.contacts,
matches: action.payload.matches, matches: action.payload.matches,
dismissedMatches: [], dismissedMatches: [],
} }
-7
View File
@@ -1291,10 +1291,3 @@ export function getDefaultCountry(location?: {
} }
return DEFAULT_PHONE_COUNTRY return DEFAULT_PHONE_COUNTRY
} }
export function getPhoneCodeFromCountryCode(countryCode: string) {
const country =
INTERNATIONAL_TELEPHONE_CODES[countryCode.toUpperCase() as CountryCode]
if (!country) throw new Error(`Country ${countryCode} not found`)
return country.code
}
+102 -23
View File
@@ -1,19 +1,30 @@
import {useCallback} from 'react' import {useCallback, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native' import {type ListRenderItemInfo, View} from 'react-native'
import * as Contacts from 'expo-contacts' import * as Contacts from 'expo-contacts'
import {type ModerationOpts} from '@atproto/api' import {
type AppBskyContactDefs,
type AppBskyContactGetSyncStatus,
type ModerationOpts,
} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import { import {
type AllNavigatorParams, type AllNavigatorParams,
type NativeStackScreenProps, type NativeStackScreenProps,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile' import {
findContactsStatusQueryKey,
useContactsMatchesQuery,
useContactsSyncStatusQuery,
} from '#/state/queries/find-contacts'
import {useAgent} from '#/state/session'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf' import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
@@ -28,6 +39,7 @@ import * as Layout from '#/components/Layout'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import * as SettingsList from './components/SettingsList' import * as SettingsList from './components/SettingsList'
@@ -36,7 +48,7 @@ type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'>
export function FindContactsSettingsScreen({}: Props) { export function FindContactsSettingsScreen({}: Props) {
const {_} = useLingui() const {_} = useLingui()
const hasInitiated = true const {data, error, refetch} = useContactsSyncStatusQuery()
return ( return (
<Layout.Screen> <Layout.Screen>
@@ -50,16 +62,27 @@ export function FindContactsSettingsScreen({}: Props) {
<Layout.Header.Slot /> <Layout.Header.Slot />
</Layout.Header.Outer> </Layout.Header.Outer>
{isNative ? ( {isNative ? (
!hasInitiated ? ( data ? (
<Intro /> !data.syncStatus ? (
<Intro />
) : (
<SyncStatus info={data.syncStatus} refetch={refetch} />
)
) : error ? (
<ErrorScreen
title={_(msg`Error getting the latest data.`)}
message={cleanError(error)}
onPressTryAgain={refetch}
/>
) : ( ) : (
<Status /> <View style={[a.flex_1, a.justify_center, a.align_center]}>
<Loader size="xl" />
</View>
) )
) : ( ) : (
<ErrorScreen <ErrorScreen
title={_(msg`Not available on this platform.`)} title={_(msg`Not available on this platform.`)}
message={_(msg`Please use the native app to sync your contacts.`)} message={_(msg`Please use the native app to sync your contacts.`)}
showHeader
/> />
)} )}
</Layout.Screen> </Layout.Screen>
@@ -117,14 +140,29 @@ function Intro() {
) )
} }
function Status() { function SyncStatus({
const {data: matches, isPending} = useProfilesQuery({ info,
handles: ['hailey.at', 'pfrazee.com', 'esb.lol'], refetch,
}) }: {
info: AppBskyContactDefs.SyncStatus
refetch: () => Promise<any>
}) {
const {data, isPending, hasNextPage, fetchNextPage, isFetchingNextPage} =
useContactsMatchesQuery()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const numMatches = matches?.profiles.length ?? 0 const [isPTR, setIsPTR] = useState(false)
const onRefresh = () => {
setIsPTR(true)
refetch().finally(() => {
setIsPTR(false)
})
}
const profiles = data?.pages?.flatMap(page => page.matches) ?? []
const numProfiles = profiles.length
const renderItem = useCallback( const renderItem = useCallback(
({item, index}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => { ({item, index}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
if (!moderationOpts) return null if (!moderationOpts) return null
@@ -132,21 +170,30 @@ function Status() {
<MatchItem <MatchItem
profile={item} profile={item}
isFirst={index === 0} isFirst={index === 0}
isLast={index === numMatches - 1} isLast={index === numProfiles - 1}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> />
) )
}, },
[numMatches, moderationOpts], [numProfiles, moderationOpts],
) )
const onEndReached = () => {
if (!hasNextPage || isFetchingNextPage) return
fetchNextPage()
}
return ( return (
<List <List
data={matches?.profiles ?? []} data={profiles}
renderItem={renderItem} renderItem={renderItem}
ListHeaderComponent={ ListHeaderComponent={
<StatusHeader numMatches={numMatches} isPending={isPending} /> <StatusHeader numMatches={info.matchesCount} isPending={isPending} />
} }
ListFooterComponent={<StatusFooter />} ListFooterComponent={<StatusFooter syncedAt={info.syncedAt} />}
onRefresh={onRefresh}
refreshing={isPTR}
onEndReached={onEndReached}
/> />
) )
} }
@@ -261,9 +308,39 @@ function StatusHeader({
) )
} }
function StatusFooter() { function StatusFooter({syncedAt}: {syncedAt: string}) {
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const t = useTheme() const t = useTheme()
const agent = useAgent()
const queryClient = useQueryClient()
const {mutate: removeData, isPending} = useMutation({
mutationFn: async () => {
await agent.app.bsky.contact.removeData({})
},
onSuccess: () => {
Toast.show(_(msg`Contacts removed`))
queryClient.setQueryData<AppBskyContactGetSyncStatus.OutputSchema>(
findContactsStatusQueryKey,
{syncStatus: undefined},
)
},
onError: err => {
if (isNetworkError(err)) {
Toast.show(
_(
msg`Failed to remove data due to a network error, please check your internet connection.`,
),
{type: 'error'},
)
} else {
logger.error('Remove data failed', {safeMessage: err})
Toast.show(_(msg`Failed to remove data. ${cleanError(err)}`), {
type: 'error',
})
}
},
})
return ( return (
<View style={[a.px_xl, a.py_xl, a.gap_4xl]}> <View style={[a.px_xl, a.py_xl, a.gap_4xl]}>
@@ -281,7 +358,7 @@ function StatusFooter() {
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans> <Trans>
Contacts last uploaded on{' '} Contacts last uploaded on{' '}
{i18n.date(new Date(), { {i18n.date(new Date(syncedAt), {
dateStyle: 'long', dateStyle: 'long',
})} })}
</Trans> </Trans>
@@ -291,9 +368,11 @@ function StatusFooter() {
<View style={[a.gap_sm, a.align_start]}> <View style={[a.gap_sm, a.align_start]}>
<Button <Button
label={_(msg`Remove all contacts`)} label={_(msg`Remove all contacts`)}
onPress={() => removeData()}
size="small" size="small"
color="negative_subtle"> color="negative_subtle"
<ButtonIcon icon={TrashIcon} /> disabled={isPending}>
<ButtonIcon icon={isPending ? Loader : TrashIcon} />
<ButtonText> <ButtonText>
<Trans>Remove all contacts</Trans> <Trans>Remove all contacts</Trans>
</ButtonText> </ButtonText>
+36
View File
@@ -0,0 +1,36 @@
import {useInfiniteQuery, useQuery} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
const RQ_KEY_ROOT = 'find-contacts'
export const findContactsStatusQueryKey = [RQ_KEY_ROOT, 'sync-status']
export function useContactsSyncStatusQuery() {
const agent = useAgent()
return useQuery({
queryKey: findContactsStatusQueryKey,
queryFn: async () => {
const status = await agent.app.bsky.contact.getSyncStatus()
return status.data
},
})
}
export const findContactsGetMatchesQueryKey = [RQ_KEY_ROOT, 'matches']
export function useContactsMatchesQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: findContactsGetMatchesQueryKey,
queryFn: async ({pageParam}) => {
const matches = await agent.app.bsky.contact.getMatches({
cursor: pageParam,
})
return matches.data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}
+30 -16
View File
@@ -82,7 +82,21 @@
"@atproto/xrpc" "^0.7.6" "@atproto/xrpc" "^0.7.6"
"@atproto/xrpc-server" "^0.10.0" "@atproto/xrpc-server" "^0.10.0"
"@atproto/api@^0.18.5", "@atproto/api@^0.18.6": "@atproto/api@^0.18.5", "@atproto/api@^0.18.7":
version "0.18.7"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.7.tgz#3175ec8f1909ddcae488183a2180de234e7acce4"
integrity sha512-vUluqN1XU5AX5tgfSJjjjUzALCMq8DjdI0jlIhRYyn2Chb0ZOCU8k0ZTpUAcuDFE2FoxxW4S3kvtlHwLMtN5dQ==
dependencies:
"@atproto/common-web" "^0.4.7"
"@atproto/lexicon" "^0.6.0"
"@atproto/syntax" "^0.4.2"
"@atproto/xrpc" "^0.7.7"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/api@^0.18.6":
version "0.18.6" version "0.18.6"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.6.tgz#04c26b97bda01cbe276dea523de6e4a184894c18" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.6.tgz#04c26b97bda01cbe276dea523de6e4a184894c18"
integrity sha512-dkzy2OHSAGgzG9GExvOiwRY73EzVD2AiD3nksng+V6erG0kwLfbmVYjoP9mq9Y16BCXr/7q9lekfogthqU614Q== integrity sha512-dkzy2OHSAGgzG9GExvOiwRY73EzVD2AiD3nksng+V6erG0kwLfbmVYjoP9mq9Y16BCXr/7q9lekfogthqU614Q==
@@ -11451,10 +11465,10 @@ expo-device@~8.0.10:
dependencies: dependencies:
ua-parser-js "^0.7.33" ua-parser-js "^0.7.33"
expo-eas-client@~1.0.7: expo-eas-client@~1.0.8:
version "1.0.7" version "1.0.8"
resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-1.0.7.tgz#9c9c7909d7bb9b6ceb4bef6875f1b9119ef22a8c" resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-1.0.8.tgz#f1fa7cbc6b6000046119466c6ded8a77e5a4b1f8"
integrity sha512-Q/b1X0fM+3beqqvffok14pjxMF600NxopdSr9WJY61fF4xllcVnALS0kEudffp9ihMOfcb5xWYqzKj6jMqYDIw== integrity sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==
expo-file-system@~19.0.20: expo-file-system@~19.0.20:
version "19.0.20" version "19.0.20"
@@ -11542,7 +11556,7 @@ expo-location@~19.0.8:
resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-19.0.8.tgz#1805393151b1286021c1ad36246b6fd095d09b55" resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-19.0.8.tgz#1805393151b1286021c1ad36246b6fd095d09b55"
integrity sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA== integrity sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==
expo-manifests@~1.0.10, expo-manifests@~1.0.9: expo-manifests@~1.0.10:
version "1.0.10" version "1.0.10"
resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.10.tgz#5dfb3db1cdf6b46fee349f1d68a25edf5e087994" resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.10.tgz#5dfb3db1cdf6b46fee349f1d68a25edf5e087994"
integrity sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ== integrity sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==
@@ -11649,18 +11663,18 @@ expo-updates-interface@~2.0.0:
integrity sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg== integrity sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==
expo-updates@~29.0.14: expo-updates@~29.0.14:
version "29.0.14" version "29.0.15"
resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-29.0.14.tgz#520a78a728eb0ff89037366df6f5cb5e887eaf04" resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-29.0.15.tgz#a135ed0915500f0e30f6d09a9e13318e99a9c651"
integrity sha512-VgXtjczQ4A/r4Jy/XEj+jWimk0vSd+GdDsYfLzl3CG/9fyQ6NXDP20PgiGfeF+A9rfA4IU3VyWdNJFBPyPPIgg== integrity sha512-6Qj+g56nnCksKKnEPQFm19dfWvYB5EggQNN3SaLbIj4LI40k/pjQwqYStEuwTU+Ow+PG0AqxIhQ3NvgVPEzLvg==
dependencies: dependencies:
"@expo/code-signing-certificates" "0.0.5" "@expo/code-signing-certificates" "0.0.5"
"@expo/plist" "^0.4.7" "@expo/plist" "^0.4.8"
"@expo/spawn-async" "^1.7.2" "@expo/spawn-async" "^1.7.2"
arg "4.1.0" arg "4.1.0"
chalk "^4.1.2" chalk "^4.1.2"
debug "^4.3.4" debug "^4.3.4"
expo-eas-client "~1.0.7" expo-eas-client "~1.0.8"
expo-manifests "~1.0.9" expo-manifests "~1.0.10"
expo-structured-headers "~5.0.0" expo-structured-headers "~5.0.0"
expo-updates-interface "~2.0.0" expo-updates-interface "~2.0.0"
getenv "^2.0.0" getenv "^2.0.0"
@@ -14332,10 +14346,10 @@ levn@^0.4.1:
prelude-ls "^1.2.1" prelude-ls "^1.2.1"
type-check "~0.4.0" type-check "~0.4.0"
libphonenumber-js@^1.12.30: libphonenumber-js@^1.12.31:
version "1.12.30" version "1.12.31"
resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.30.tgz#8f37c6d0546e89a399085d329214a583642e611a" resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.31.tgz#3cdb45641c6b77228dd1238f3d810c3bb5d91199"
integrity sha512-KxH7uIJFD6+cR6nhdh+wY6prFiH26A3W/W1gTMXnng2PXSwVfi5MhYkdq3Z2Y7vhBVa1/5VJgpNtI76UM2njGA== integrity sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==
lighthouse-logger@^1.0.0: lighthouse-logger@^1.0.0:
version "1.4.2" version "1.4.2"