[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)
- Launch a Postgres database on port 5432
- `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.
+2 -2
View File
@@ -72,7 +72,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.6",
"@atproto/api": "^0.18.7",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
@@ -171,7 +171,7 @@
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
"libphonenumber-js": "^1.12.30",
"libphonenumber-js": "^1.12.31",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"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
ref?: React.Ref<TextInput>
numberOfDigits?: number
onComplete?: () => void
onComplete?: (code: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
@@ -41,7 +41,7 @@ export function OTPInput({
text = text.slice(0, numberOfDigits)
onChange(text)
if (text.length === numberOfDigits) {
onComplete?.()
onComplete?.(text)
innerRef.current?.blur()
}
}
@@ -108,11 +108,7 @@ export function OTPInput({
ios: 'one-time-code',
})}
autoFocus
onFocus={() => {
onChangeText('')
setSelection({start: 0, end: 0})
onFocus()
}}
onFocus={onFocus}
onBlur={onBlur}
maxLength={numberOfDigits}
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 {
isSupportedCountry,
ParseError,
parsePhoneNumber,
parsePhoneNumberWithError,
@@ -61,12 +62,45 @@ export function processPhoneNumber(
}
}
/**
* Format a phone number as the international format with the prefix
* removed.
*/
function formatInternationalWithoutCountryCode(phoneNumber: PhoneNumber) {
const intl = phoneNumber.formatInternational()
const prefix = '+' + phoneNumber.countryCallingCode
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(
text: string,
): {countryCode: CountryCode; rest: string} | undefined {
@@ -89,3 +123,37 @@ export function getCountryCodeFromPastedNumber(
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 {msg, t, Trans} from '@lingui/macro'
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 {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
import {useAgent} from '#/state/session'
import {atoms as a, 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 {filterMatchedNumbers, normalizeContactBook} from '../contacts'
import {constructFullPhoneNumber} from '../phone-number'
import {type Action, type State} from '../state'
export function GetContacts({
state,
dispatch,
onCancel,
}: {
@@ -23,20 +29,58 @@ export function GetContacts({
onCancel: () => void
}) {
const {_} = useLingui()
const agent = useAgent()
const insets = useSafeAreaInsets()
const gutters = useGutters([0, 'wide'])
const queryClient = useQueryClient()
const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({
mutationFn: async (_contacts: Contacts.ExistingContact[]) => {
await wait(2e3, () => {})
mutationFn: async (contacts: Contacts.ExistingContact[]) => {
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({
type: 'SYNC_CONTACTS_SUCCESS',
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'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {android, atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
@@ -23,6 +24,7 @@ import {Text} from '#/components/Typography'
import {useGeolocation} from '#/geolocation'
import {isFindContactsFeatureEnabled} from '../country-whitelist'
import {
constructFullPhoneNumber,
getCountryCodeFromPastedNumber,
processPhoneNumber,
} from '../phone-number'
@@ -41,6 +43,7 @@ export function PhoneInput({
}) {
const {_} = useLingui()
const t = useTheme()
const agent = useAgent()
const location = useGeolocation()
const [countryCode, setCountryCode] = useState(
() => state.phoneCountryCode ?? getDefaultCountry(location),
@@ -54,13 +57,16 @@ export function PhoneInput({
const [formatError, setFormatError] = useState('')
const {mutate: submit, isPending} = useMutation({
mutationFn: async ({}: {
mutationFn: async ({
phoneCountryCode,
phoneNumber,
}: {
phoneCountryCode: CountryCode
phoneNumber: string
}) => {
// get otp
await new Promise(resolve => {
setTimeout(resolve, 500)
// sends a onetime code to the user's phone number
await agent.app.bsky.contact.startPhoneVerification({
phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber),
})
},
onSuccess: (_data, {phoneCountryCode, phoneNumber}) => {
@@ -92,6 +98,7 @@ export function PhoneInput({
const onSubmitNumber = () => {
if (!isFeatureEnabled) return
if (!phoneNumber) return
const result = processPhoneNumber(phoneNumber, countryCode)
if (result.valid) {
setPhoneNumber(result.formatted)
@@ -4,10 +4,10 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {getPhoneCodeFromCountryCode} from '#/lib/international-telephone-codes'
import {clamp} from '#/lib/numbers'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
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 {Text} from '#/components/Typography'
import {OTPInput} from '../components/OTPInput'
import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state'
export function VerifyNumber({
state,
dispatch,
@@ -33,6 +35,7 @@ export function VerifyNumber({
}) {
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const gutters = useGutters([0, 'wide'])
const [otpCode, setOtpCode] = useState('')
@@ -48,25 +51,36 @@ export function VerifyNumber({
setError(null)
}
const phone = useMemo(
() => constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
[state.phoneCountryCode, state.phoneNumber],
)
const prettyNumber = useMemo(() => prettyPhoneNumber(phone), [phone])
const {
mutate: verifyNumber,
isPending,
isSuccess,
} = useMutation({
mutationFn: async (_code: string) => {
await new Promise(resolve => setTimeout(resolve, 500))
return 'success'
mutationFn: async (code: string) => {
const res = await agent.app.bsky.contact.verifyPhone({code, phone})
return res.data.token
},
onSuccess: async () => {
dispatch({
type: 'VERIFY_PHONE_NUMBER_SUCCESS',
payload: {
token: 'example_token',
},
})
onSuccess: async token => {
// let the success state show for a moment
setTimeout(() => {
dispatch({
type: 'VERIFY_PHONE_NUMBER_SUCCESS',
payload: {
token,
},
})
}, 1000)
},
onMutate: () => setError(null),
onError: err => {
setOtpCode('')
if (isNetworkError(err)) {
setError({
retryable: true,
@@ -95,13 +109,14 @@ export function VerifyNumber({
const {mutate: resendCode, isPending: isResendingCode} = useMutation({
mutationFn: async () => {
await new Promise(resolve => setTimeout(resolve, 2000))
await agent.app.bsky.contact.startPhoneVerification({phone: phone})
},
onSuccess: () => {
dispatch({type: 'RESEND_VERIFICATION_CODE'})
Toast.show(_(msg`Code resent`))
Toast.show(_(msg`A new code has been sent`))
},
onMutate: () => {
setOtpCode('')
setError(null)
},
onError: err => {
@@ -116,11 +131,6 @@ export function VerifyNumber({
},
})
const phoneCode = useMemo(
() => getPhoneCodeFromCountryCode(state.phoneCountryCode),
[state.phoneCountryCode],
)
const onPressBack = useOnPressBackButton()
return (
@@ -156,9 +166,7 @@ export function VerifyNumber({
a.leading_snug,
a.mt_sm,
]}>
<Trans>
Enter the 6-digit code sent to {phoneCode} {state.phoneNumber}
</Trans>
<Trans>Enter the 6-digit code sent to {prettyNumber}</Trans>
</Text>
<View style={[a.mt_2xl]}>
<OTPInput
@@ -167,7 +175,7 @@ export function VerifyNumber({
)}
value={otpCode}
onChange={setOtpCode}
onComplete={() => verifyNumber(otpCode)}
onComplete={code => verifyNumber(code)}
/>
</View>
<View style={[a.mt_sm]}>
@@ -259,7 +267,7 @@ function OTPStatus({
<View style={[a.w_full, a.align_center]}>
{text && (
<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
style={[
{color: textColor},
@@ -15,7 +15,6 @@ import {
useProfileShadow,
} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -86,13 +85,6 @@ export function ViewMatches({
const insets = useSafeAreaInsets()
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 {
state: searchFocused,
@@ -101,7 +93,7 @@ export function ViewMatches({
} = useInteractionState()
const followableDids = state.matches
.map(match => match.profile.did)
.map(match => match.did)
.filter(did => !state.dismissedMatches.includes(did))
const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0)
@@ -144,20 +136,16 @@ export function ViewMatches({
if (searchFocused || search.length > 0) {
for (const match of state.matches) {
const profile = match.profile
if (state.dismissedMatches.includes(profile.did)) continue
if (state.dismissedMatches.includes(match.did)) continue
if (
search.length === 0 ||
(profile.displayName ?? '')
(match.displayName ?? '')
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase()) ||
profile.handle
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())
match.handle.toLocaleLowerCase().includes(search.toLocaleLowerCase())
) {
all.push({type: 'match', profile})
all.push({type: 'match', profile: match})
}
}
@@ -179,14 +167,13 @@ export function ViewMatches({
}
} else {
const matches = state.matches.filter(
match => !state.dismissedMatches.includes(match.profile.did),
match => !state.dismissedMatches.includes(match.did),
)
if (matches.length > 0) {
all.push({type: 'matches header', count: matches.length})
for (const match of matches) {
const profile = match.profile
all.push({type: 'match', profile})
all.push({type: 'match', profile: match})
}
if (state.contacts.length > 0) {
+9 -9
View File
@@ -7,12 +7,6 @@ import type * as bsky from '#/types/bsky'
export type Contact = ExistingContact
// TODO: replace with lexicon type
export type Match = {
index?: number
profile: bsky.profile.AnyProfileView
}
export type State =
| {
step: '1: phone input'
@@ -27,13 +21,15 @@ export type State =
}
| {
step: '3: get contacts'
phoneCountryCode: CountryCode
phoneNumber: string
token: string
contacts?: Contact[]
}
| {
step: '4: view matches'
contacts: Contact[]
matches: Match[]
matches: bsky.profile.AnyProfileView[]
// rather than mutating `matches`, we keep track of dismissed matches
// so we can roll back optimistic updates
dismissedMatches: string[]
@@ -65,7 +61,9 @@ export type Action =
| {
type: 'SYNC_CONTACTS_SUCCESS'
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 {
step: '3: get contacts',
token: action.payload.token,
phoneCountryCode: state.phoneCountryCode,
phoneNumber: state.phoneNumber,
}
}
case 'BACK': {
@@ -127,7 +127,7 @@ function reducer(state: State, action: Action): State {
assertCurrentStep(state, '3: get contacts')
return {
step: '4: view matches',
contacts: state.contacts ?? [],
contacts: action.payload.contacts,
matches: action.payload.matches,
dismissedMatches: [],
}
-7
View File
@@ -1291,10 +1291,3 @@ export function getDefaultCountry(location?: {
}
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 * 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 {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
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 {List} from '#/view/com/util/List'
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 {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import * as SettingsList from './components/SettingsList'
@@ -36,7 +48,7 @@ type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'>
export function FindContactsSettingsScreen({}: Props) {
const {_} = useLingui()
const hasInitiated = true
const {data, error, refetch} = useContactsSyncStatusQuery()
return (
<Layout.Screen>
@@ -50,16 +62,27 @@ export function FindContactsSettingsScreen({}: Props) {
<Layout.Header.Slot />
</Layout.Header.Outer>
{isNative ? (
!hasInitiated ? (
<Intro />
data ? (
!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
title={_(msg`Not available on this platform.`)}
message={_(msg`Please use the native app to sync your contacts.`)}
showHeader
/>
)}
</Layout.Screen>
@@ -117,14 +140,29 @@ function Intro() {
)
}
function Status() {
const {data: matches, isPending} = useProfilesQuery({
handles: ['hailey.at', 'pfrazee.com', 'esb.lol'],
})
function SyncStatus({
info,
refetch,
}: {
info: AppBskyContactDefs.SyncStatus
refetch: () => Promise<any>
}) {
const {data, isPending, hasNextPage, fetchNextPage, isFetchingNextPage} =
useContactsMatchesQuery()
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(
({item, index}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
if (!moderationOpts) return null
@@ -132,21 +170,30 @@ function Status() {
<MatchItem
profile={item}
isFirst={index === 0}
isLast={index === numMatches - 1}
isLast={index === numProfiles - 1}
moderationOpts={moderationOpts}
/>
)
},
[numMatches, moderationOpts],
[numProfiles, moderationOpts],
)
const onEndReached = () => {
if (!hasNextPage || isFetchingNextPage) return
fetchNextPage()
}
return (
<List
data={matches?.profiles ?? []}
data={profiles}
renderItem={renderItem}
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 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 (
<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]}>
<Trans>
Contacts last uploaded on{' '}
{i18n.date(new Date(), {
{i18n.date(new Date(syncedAt), {
dateStyle: 'long',
})}
</Trans>
@@ -291,9 +368,11 @@ function StatusFooter() {
<View style={[a.gap_sm, a.align_start]}>
<Button
label={_(msg`Remove all contacts`)}
onPress={() => removeData()}
size="small"
color="negative_subtle">
<ButtonIcon icon={TrashIcon} />
color="negative_subtle"
disabled={isPending}>
<ButtonIcon icon={isPending ? Loader : TrashIcon} />
<ButtonText>
<Trans>Remove all contacts</Trans>
</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-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"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.6.tgz#04c26b97bda01cbe276dea523de6e4a184894c18"
integrity sha512-dkzy2OHSAGgzG9GExvOiwRY73EzVD2AiD3nksng+V6erG0kwLfbmVYjoP9mq9Y16BCXr/7q9lekfogthqU614Q==
@@ -11451,10 +11465,10 @@ expo-device@~8.0.10:
dependencies:
ua-parser-js "^0.7.33"
expo-eas-client@~1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-1.0.7.tgz#9c9c7909d7bb9b6ceb4bef6875f1b9119ef22a8c"
integrity sha512-Q/b1X0fM+3beqqvffok14pjxMF600NxopdSr9WJY61fF4xllcVnALS0kEudffp9ihMOfcb5xWYqzKj6jMqYDIw==
expo-eas-client@~1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-1.0.8.tgz#f1fa7cbc6b6000046119466c6ded8a77e5a4b1f8"
integrity sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==
expo-file-system@~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"
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"
resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.10.tgz#5dfb3db1cdf6b46fee349f1d68a25edf5e087994"
integrity sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==
@@ -11649,18 +11663,18 @@ expo-updates-interface@~2.0.0:
integrity sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==
expo-updates@~29.0.14:
version "29.0.14"
resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-29.0.14.tgz#520a78a728eb0ff89037366df6f5cb5e887eaf04"
integrity sha512-VgXtjczQ4A/r4Jy/XEj+jWimk0vSd+GdDsYfLzl3CG/9fyQ6NXDP20PgiGfeF+A9rfA4IU3VyWdNJFBPyPPIgg==
version "29.0.15"
resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-29.0.15.tgz#a135ed0915500f0e30f6d09a9e13318e99a9c651"
integrity sha512-6Qj+g56nnCksKKnEPQFm19dfWvYB5EggQNN3SaLbIj4LI40k/pjQwqYStEuwTU+Ow+PG0AqxIhQ3NvgVPEzLvg==
dependencies:
"@expo/code-signing-certificates" "0.0.5"
"@expo/plist" "^0.4.7"
"@expo/plist" "^0.4.8"
"@expo/spawn-async" "^1.7.2"
arg "4.1.0"
chalk "^4.1.2"
debug "^4.3.4"
expo-eas-client "~1.0.7"
expo-manifests "~1.0.9"
expo-eas-client "~1.0.8"
expo-manifests "~1.0.10"
expo-structured-headers "~5.0.0"
expo-updates-interface "~2.0.0"
getenv "^2.0.0"
@@ -14332,10 +14346,10 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
libphonenumber-js@^1.12.30:
version "1.12.30"
resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.30.tgz#8f37c6d0546e89a399085d329214a583642e611a"
integrity sha512-KxH7uIJFD6+cR6nhdh+wY6prFiH26A3W/W1gTMXnng2PXSwVfi5MhYkdq3Z2Y7vhBVa1/5VJgpNtI76UM2njGA==
libphonenumber-js@^1.12.31:
version "1.12.31"
resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.31.tgz#3cdb45641c6b77228dd1238f3d810c3bb5d91199"
integrity sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==
lighthouse-logger@^1.0.0:
version "1.4.2"