migrate the contact queries and phone verification to the appview client

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-03 17:54:04 +03:00
parent 8a87a5fdea
commit 1a6bfd8a6a
5 changed files with 132 additions and 126 deletions
+20 -20
View File
@@ -2,7 +2,6 @@ import {useState} from 'react'
import {Keyboard, View} from 'react-native' import {Keyboard, View} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller' import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyContactStartPhoneVerification} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
@@ -14,8 +13,9 @@ import {
getDefaultCountry, getDefaultCountry,
} 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 {matchXrpcError} from '#/lib/xrpc-error'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAppviewClient} from '#/state/session'
import {OnboardingPosition} from '#/screens/Onboarding/Layout' import {OnboardingPosition} from '#/screens/Onboarding/Layout'
import { import {
android, android,
@@ -34,6 +34,7 @@ import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {useGeolocation} from '#/geolocation' import {useGeolocation} from '#/geolocation'
import {app} from '#/lexicons'
import {isFindContactsFeatureEnabled} from '../country-allowlist' import {isFindContactsFeatureEnabled} from '../country-allowlist'
import { import {
constructFullPhoneNumber, constructFullPhoneNumber,
@@ -56,7 +57,7 @@ export function PhoneInput({
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const t = useTheme() const t = useTheme()
const agent = useAgent() const client = useAppviewClient()
const location = useGeolocation() const location = useGeolocation()
const [countryCode, setCountryCode] = useState( const [countryCode, setCountryCode] = useState(
() => state.phoneCountryCode ?? getDefaultCountry(location), () => state.phoneCountryCode ?? getDefaultCountry(location),
@@ -78,7 +79,7 @@ export function PhoneInput({
phoneNumber: string phoneNumber: string
}) => { }) => {
// sends a onetime code to the user's phone number // sends a onetime code to the user's phone number
await agent.app.bsky.contact.startPhoneVerification({ await client.call(app.bsky.contact.startPhoneVerification, {
phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber), phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber),
}) })
}, },
@@ -102,23 +103,22 @@ export function PhoneInput({
msg`A network error occurred. Please check your internet connection`, msg`A network error occurred. Please check your internet connection`,
), ),
) )
} else if ( return
err instanceof
AppBskyContactStartPhoneVerification.RateLimitExceededError
) {
setError(_(msg`Rate limit exceeded. Please try again later.`))
} else if (
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
) {
setError(
_(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
)
} else {
logger.error('Verify phone number failed', {safeMessage: err})
setError(_(msg`An error occurred. ${cleanError(err)}`))
} }
switch (matchXrpcError(err, app.bsky.contact.startPhoneVerification)) {
case 'RateLimitExceeded':
setError(_(msg`Rate limit exceeded. Please try again later.`))
return
case 'InvalidPhone':
setError(
_(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
)
return
}
logger.error('Verify phone number failed', {safeMessage: err})
setError(_(msg`An error occurred. ${cleanError(err)}`))
}, },
}) })
@@ -1,9 +1,5 @@
import {useEffect, useMemo, useState} from 'react' import {useEffect, useMemo, useState} from 'react'
import {Text as NestedText, View} from 'react-native' import {Text as NestedText, View} from 'react-native'
import {
AppBskyContactStartPhoneVerification,
AppBskyContactVerifyPhone,
} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
@@ -11,8 +7,9 @@ import {useMutation} from '@tanstack/react-query'
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 {matchXrpcError} from '#/lib/xrpc-error'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAppviewClient} from '#/state/session'
import {OnboardingPosition} from '#/screens/Onboarding/Layout' import {OnboardingPosition} from '#/screens/Onboarding/Layout'
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'
@@ -25,6 +22,7 @@ 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 {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {app} from '#/lexicons'
import {OTPInput} from '../components/OTPInput' import {OTPInput} from '../components/OTPInput'
import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number' import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state' import {type Action, type State, useOnPressBackButton} from '../state'
@@ -43,7 +41,7 @@ export function VerifyNumber({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const agent = useAgent() const client = useAppviewClient()
const gutters = useGutters([0, 'wide']) const gutters = useGutters([0, 'wide'])
const [otpCode, setOtpCode] = useState('') const [otpCode, setOtpCode] = useState('')
@@ -72,8 +70,11 @@ export function VerifyNumber({
isSuccess, isSuccess,
} = useMutation({ } = useMutation({
mutationFn: async (code: string) => { mutationFn: async (code: string) => {
const res = await agent.app.bsky.contact.verifyPhone({code, phone}) const data = await client.call(app.bsky.contact.verifyPhone, {
return res.data.token code,
phone,
})
return data.token
}, },
onSuccess: async token => { onSuccess: async token => {
// let the success state show for a moment // let the success state show for a moment
@@ -99,44 +100,47 @@ export function VerifyNumber({
msg`A network error occurred. Please check your internet connection.`, msg`A network error occurred. Please check your internet connection.`,
), ),
}) })
} else if (err instanceof AppBskyContactVerifyPhone.InvalidCodeError) { return
setError({
retryable: true,
isResendError: true,
message: _(msg`This code is invalid. Resend to get a new code.`),
})
} else if (err instanceof AppBskyContactVerifyPhone.InvalidPhoneError) {
setError({
retryable: false,
isResendError: false,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
} else if (
err instanceof AppBskyContactVerifyPhone.RateLimitExceededError
) {
setError({
retryable: true,
isResendError: false,
message: _(
msg`Too many attempts. Please wait a few minutes and try again.`,
),
})
} else {
logger.error('Verify phone number failed', {safeMessage: err})
setError({
retryable: true,
isResendError: false,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
} }
switch (matchXrpcError(err, app.bsky.contact.verifyPhone)) {
case 'InvalidCode':
setError({
retryable: true,
isResendError: true,
message: _(msg`This code is invalid. Resend to get a new code.`),
})
return
case 'InvalidPhone':
setError({
retryable: false,
isResendError: false,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
return
case 'RateLimitExceeded':
setError({
retryable: true,
isResendError: false,
message: _(
msg`Too many attempts. Please wait a few minutes and try again.`,
),
})
return
}
logger.error('Verify phone number failed', {safeMessage: err})
setError({
retryable: true,
isResendError: false,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
}, },
}) })
const {mutate: resendCode, isPending: isResendingCode} = useMutation({ const {mutate: resendCode, isPending: isResendingCode} = useMutation({
mutationFn: async () => { mutationFn: async () => {
await agent.app.bsky.contact.startPhoneVerification({phone: phone}) await client.call(app.bsky.contact.startPhoneVerification, {phone: phone})
}, },
onSuccess: () => { onSuccess: () => {
dispatch({type: 'RESEND_VERIFICATION_CODE'}) dispatch({type: 'RESEND_VERIFICATION_CODE'})
@@ -155,35 +159,34 @@ export function VerifyNumber({
msg`A network error occurred. Please check your internet connection.`, msg`A network error occurred. Please check your internet connection.`,
), ),
}) })
} else if ( return
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
) {
setError({
retryable: false,
isResendError: true,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
} else if (
err instanceof
AppBskyContactStartPhoneVerification.RateLimitExceededError
) {
setError({
retryable: true,
isResendError: true,
message: _(
msg`Too many codes sent. Please wait a few minutes and try again.`,
),
})
} else {
logger.error('Resend failed', {safeMessage: err})
setError({
retryable: true,
isResendError: true,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
} }
switch (matchXrpcError(err, app.bsky.contact.startPhoneVerification)) {
case 'InvalidPhone':
setError({
retryable: false,
isResendError: true,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
return
case 'RateLimitExceeded':
setError({
retryable: true,
isResendError: true,
message: _(
msg`Too many codes sent. Please wait a few minutes and try again.`,
),
})
return
}
logger.error('Resend failed', {safeMessage: err})
setError({
retryable: true,
isResendError: true,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
}, },
}) })
@@ -3,6 +3,7 @@ import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as SMS from 'expo-sms' import * as SMS from 'expo-sms'
import {type ModerationOpts} from '@atproto/api' import {type ModerationOpts} from '@atproto/api'
import {type DidString} from '@atproto/syntax'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro' import {Plural, Trans} from '@lingui/react/macro'
@@ -20,7 +21,7 @@ import {
optimisticRemoveMatch, optimisticRemoveMatch,
useMatchesPassthroughQuery, useMatchesPassthroughQuery,
} from '#/state/queries/find-contacts' } from '#/state/queries/find-contacts'
import {useAgent, useSession} from '#/state/session' import {useAgent, useAppviewClient, 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'
import {OnboardingPosition} from '#/screens/Onboarding/Layout' import {OnboardingPosition} from '#/screens/Onboarding/Layout'
@@ -41,6 +42,7 @@ import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {app} from '#/lexicons'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {InviteInfo} from '../components/InviteInfo' import {InviteInfo} from '../components/InviteInfo'
import {type Action, type Contact, type Match, type State} from '../state' import {type Action, type Contact, type Match, type State} from '../state'
@@ -90,6 +92,7 @@ export function ViewMatches({
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const agent = useAgent()
const client = useAppviewClient()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const listRef = useRef<ListMethods>(null) const listRef = useRef<ListMethods>(null)
@@ -218,7 +221,9 @@ export function ViewMatches({
const {mutate: dismissMatch} = useMutation({ const {mutate: dismissMatch} = useMutation({
mutationFn: async (did: string) => { mutationFn: async (did: string) => {
await agent.app.bsky.contact.dismissMatch({subject: did}) await client.call(app.bsky.contact.dismissMatch, {
subject: did as DidString,
})
}, },
onMutate: did => { onMutate: did => {
ax.metric('contacts:matches:dismiss', {entryPoint: context}) ax.metric('contacts:matches:dismiss', {entryPoint: context})
+15 -14
View File
@@ -1,11 +1,8 @@
import {useCallback, useEffect, useState} from 'react' import {useCallback, useEffect, 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 { import {type AppBskyContactDefs, type ModerationOpts} from '@atproto/api'
type AppBskyContactDefs, import {type DidString} from '@atproto/syntax'
type AppBskyContactGetSyncStatus,
type ModerationOpts,
} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro' import {Plural, Trans} from '@lingui/react/macro'
@@ -32,7 +29,7 @@ import {
useContactsMatchesQuery, useContactsMatchesQuery,
useContactsSyncStatusQuery, useContactsSyncStatusQuery,
} from '#/state/queries/find-contacts' } from '#/state/queries/find-contacts'
import {useAgent, useSession} from '#/state/session' import {useAgent, useAppviewClient, useSession} 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'
@@ -52,6 +49,7 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends' import {InviteFriendsDialog} from '#/features/inviteFriends'
import {app} from '#/lexicons'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {bulkWriteFollows} from '../Onboarding/util' import {bulkWriteFollows} from '../Onboarding/util'
@@ -194,7 +192,7 @@ function SyncStatus({
refetchStatus: () => Promise<any> refetchStatus: () => Promise<any>
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
const agent = useAgent() const client = useAppviewClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@@ -219,7 +217,9 @@ function SyncStatus({
const {mutate: dismissMatch} = useMutation({ const {mutate: dismissMatch} = useMutation({
mutationFn: async (did: string) => { mutationFn: async (did: string) => {
await agent.app.bsky.contact.dismissMatch({subject: did}) await client.call(app.bsky.contact.dismissMatch, {
subject: did as DidString,
})
}, },
onMutate: async (did: string) => { onMutate: async (did: string) => {
ax.metric('contacts:settings:dismiss', {}) ax.metric('contacts:settings:dismiss', {})
@@ -371,6 +371,7 @@ function StatusHeader({
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const agent = useAgent() const agent = useAgent()
const client = useAppviewClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -384,12 +385,12 @@ function StatusHeader({
let cursor: string | undefined let cursor: string | undefined
do { do {
const page = await agent.app.bsky.contact.getMatches({ const page = await client.call(app.bsky.contact.getMatches, {
limit: 100, limit: 100,
cursor, cursor,
}) })
cursor = page.data.cursor cursor = page.cursor
for (const profile of page.data.matches) { for (const profile of page.matches) {
if ( if (
profile.did !== currentAccount?.did && profile.did !== currentAccount?.did &&
!isBlockedOrBlocking(profile) && !isBlockedOrBlocking(profile) &&
@@ -487,17 +488,17 @@ function StatusFooter({syncedAt}: {syncedAt: string}) {
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const agent = useAgent() const client = useAppviewClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {mutate: removeData, isPending} = useMutation({ const {mutate: removeData, isPending} = useMutation({
mutationFn: async () => { mutationFn: async () => {
await agent.app.bsky.contact.removeData({}) await client.call(app.bsky.contact.removeData, {})
}, },
onMutate: () => ax.metric('contacts:settings:removeData', {}), onMutate: () => ax.metric('contacts:settings:removeData', {}),
onSuccess: () => { onSuccess: () => {
Toast.show(_(msg`Contacts removed`)) Toast.show(_(msg`Contacts removed`))
queryClient.setQueryData<AppBskyContactGetSyncStatus.OutputSchema>( queryClient.setQueryData<app.bsky.contact.getSyncStatus.$OutputBody>(
findContactsStatusQueryKey, findContactsStatusQueryKey,
{syncStatus: undefined}, {syncStatus: undefined},
) )
+19 -22
View File
@@ -1,4 +1,3 @@
import {type AppBskyContactGetMatches} from '@atproto/api'
import { import {
type InfiniteData, type InfiniteData,
type QueryClient, type QueryClient,
@@ -6,8 +5,9 @@ import {
useQuery, useQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useAgent} from '#/state/session' import {useAppviewClient} from '#/state/session'
import {type Match} from '#/components/contacts/state' import {type Match} from '#/components/contacts/state'
import {app} from '#/lexicons'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {STALE} from '.' import {STALE} from '.'
@@ -15,13 +15,12 @@ const RQ_KEY_ROOT = 'find-contacts'
export const findContactsStatusQueryKey = [RQ_KEY_ROOT, 'sync-status'] export const findContactsStatusQueryKey = [RQ_KEY_ROOT, 'sync-status']
export function useContactsSyncStatusQuery() { export function useContactsSyncStatusQuery() {
const agent = useAgent() const client = useAppviewClient()
return useQuery({ return useQuery({
queryKey: findContactsStatusQueryKey, queryKey: findContactsStatusQueryKey,
queryFn: async () => { queryFn: async () => {
const status = await agent.app.bsky.contact.getSyncStatus() return await client.call(app.bsky.contact.getSyncStatus, {})
return status.data
}, },
staleTime: STALE.SECONDS.THIRTY, staleTime: STALE.SECONDS.THIRTY,
}) })
@@ -30,15 +29,14 @@ export function useContactsSyncStatusQuery() {
export const findContactsGetMatchesQueryKey = [RQ_KEY_ROOT, 'matches'] export const findContactsGetMatchesQueryKey = [RQ_KEY_ROOT, 'matches']
export function useContactsMatchesQuery() { export function useContactsMatchesQuery() {
const agent = useAgent() const client = useAppviewClient()
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: findContactsGetMatchesQueryKey, queryKey: findContactsGetMatchesQueryKey,
queryFn: async ({pageParam}) => { queryFn: async ({pageParam}) => {
const matches = await agent.app.bsky.contact.getMatches({ return await client.call(app.bsky.contact.getMatches, {
cursor: pageParam, cursor: pageParam,
}) })
return matches.data
}, },
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor, getNextPageParam: lastPage => lastPage.cursor,
@@ -47,20 +45,19 @@ export function useContactsMatchesQuery() {
} }
export function optimisticRemoveMatch(queryClient: QueryClient, did: string) { export function optimisticRemoveMatch(queryClient: QueryClient, did: string) {
queryClient.setQueryData<InfiniteData<AppBskyContactGetMatches.OutputSchema>>( queryClient.setQueryData<
findContactsGetMatchesQueryKey, InfiniteData<app.bsky.contact.getMatches.$OutputBody>
old => { >(findContactsGetMatchesQueryKey, old => {
if (!old) return old if (!old) return old
return { return {
...old, ...old,
pages: old.pages.map(page => ({ pages: old.pages.map(page => ({
...page, ...page,
matches: page.matches.filter(match => match.did !== did), matches: page.matches.filter(match => match.did !== did),
})), })),
} }
}, })
)
} }
export const findContactsMatchesPassthroughQueryKey = (dids: string[]) => [ export const findContactsMatchesPassthroughQueryKey = (dids: string[]) => [
@@ -95,7 +92,7 @@ export function* findAllProfilesInQueryData(
did: string, did: string,
): Generator<bsky.profile.AnyProfileView, void> { ): Generator<bsky.profile.AnyProfileView, void> {
const queryDatas = queryClient.getQueriesData< const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyContactGetMatches.OutputSchema> InfiniteData<app.bsky.contact.getMatches.$OutputBody>
>({ >({
queryKey: findContactsGetMatchesQueryKey, queryKey: findContactsGetMatchesQueryKey,
}) })