[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:
@@ -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
@@ -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={[
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user