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