From ff7f07f8c752433f13c812d900064c8651c4a356 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 10 Dec 2025 22:13:25 +0200 Subject: [PATCH] [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 --- docs/build.md | 2 + package.json | 4 +- ....0.14.patch => expo-updates+29.0.15.patch} | 0 ...patch.md => expo-updates+29.0.15.patch.md} | 0 .../contacts/SyncContactsFlow.web.tsx | 3 - .../contacts/components/OTPInput.tsx | 10 +- src/components/contacts/contacts.ts | 73 ++++++++++ src/components/contacts/phone-number.ts | 68 ++++++++++ .../contacts/screens/GetContacts.tsx | 56 +++++++- .../contacts/screens/PhoneInput.tsx | 15 ++- .../contacts/screens/VerifyNumber.tsx | 54 ++++---- .../contacts/screens/ViewMatches.tsx | 27 +--- src/components/contacts/state.ts | 18 +-- src/lib/international-telephone-codes.ts | 7 - src/screens/Settings/FindContactsSettings.tsx | 125 ++++++++++++++---- src/state/queries/find-contacts.ts | 36 +++++ yarn.lock | 46 ++++--- 17 files changed, 424 insertions(+), 120 deletions(-) rename patches/{expo-updates+29.0.14.patch => expo-updates+29.0.15.patch} (100%) rename patches/{expo-updates+29.0.14.patch.md => expo-updates+29.0.15.patch.md} (100%) delete mode 100644 src/components/contacts/SyncContactsFlow.web.tsx create mode 100644 src/components/contacts/contacts.ts create mode 100644 src/state/queries/find-contacts.ts diff --git a/docs/build.md b/docs/build.md index fe95bb989a..bcae2ece09 100644 --- a/docs/build.md +++ b/docs/build.md @@ -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. diff --git a/package.json b/package.json index 5d4d7ff872..66d38825aa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/patches/expo-updates+29.0.14.patch b/patches/expo-updates+29.0.15.patch similarity index 100% rename from patches/expo-updates+29.0.14.patch rename to patches/expo-updates+29.0.15.patch diff --git a/patches/expo-updates+29.0.14.patch.md b/patches/expo-updates+29.0.15.patch.md similarity index 100% rename from patches/expo-updates+29.0.14.patch.md rename to patches/expo-updates+29.0.15.patch.md diff --git a/src/components/contacts/SyncContactsFlow.web.tsx b/src/components/contacts/SyncContactsFlow.web.tsx deleted file mode 100644 index d40435f6fa..0000000000 --- a/src/components/contacts/SyncContactsFlow.web.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function SyncContactsFlow() { - throw new Error('SyncContactsFlow is not available on web') -} diff --git a/src/components/contacts/components/OTPInput.tsx b/src/components/contacts/components/OTPInput.tsx index 0d0e96c74b..c7575fdfe9 100644 --- a/src/components/contacts/components/OTPInput.tsx +++ b/src/components/contacts/components/OTPInput.tsx @@ -27,7 +27,7 @@ export function OTPInput({ onChange: (text: string) => void ref?: React.Ref 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={[ diff --git a/src/components/contacts/contacts.ts b/src/components/contacts/contacts.ts new file mode 100644 index 0000000000..2c26da49e8 --- /dev/null +++ b/src/components/contacts/contacts.ts @@ -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 +} { + const phoneNumbers: string[] = [] + const indexToContactId = new Map() + + 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, +) { + const filteredIds = new Set() + + 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)) +} diff --git a/src/components/contacts/phone-number.ts b/src/components/contacts/phone-number.ts index df22aa2429..ba36cc8907 100644 --- a/src/components/contacts/phone-number.ts +++ b/src/components/contacts/phone-number.ts @@ -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 + } +} diff --git a/src/components/contacts/screens/GetContacts.tsx b/src/components/contacts/screens/GetContacts.tsx index f8d36d99d8..ac0bdd96fe 100644 --- a/src/components/contacts/screens/GetContacts.tsx +++ b/src/components/contacts/screens/GetContacts.tsx @@ -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', + }) + } }, }) diff --git a/src/components/contacts/screens/PhoneInput.tsx b/src/components/contacts/screens/PhoneInput.tsx index 55fd94adaa..f751c2aeaa 100644 --- a/src/components/contacts/screens/PhoneInput.tsx +++ b/src/components/contacts/screens/PhoneInput.tsx @@ -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) diff --git a/src/components/contacts/screens/VerifyNumber.tsx b/src/components/contacts/screens/VerifyNumber.tsx index ed746ece97..a0a4c98fa7 100644 --- a/src/components/contacts/screens/VerifyNumber.tsx +++ b/src/components/contacts/screens/VerifyNumber.tsx @@ -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, ]}> - - Enter the 6-digit code sent to {phoneCode} {state.phoneNumber} - + Enter the 6-digit code sent to {prettyNumber} verifyNumber(otpCode)} + onComplete={code => verifyNumber(code)} /> @@ -259,7 +267,7 @@ function OTPStatus({ {text && ( - {Icon && } + {Icon && } (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) { diff --git a/src/components/contacts/state.ts b/src/components/contacts/state.ts index 3949f901e3..b2e4cf8837 100644 --- a/src/components/contacts/state.ts +++ b/src/components/contacts/state.ts @@ -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: [], } diff --git a/src/lib/international-telephone-codes.ts b/src/lib/international-telephone-codes.ts index b2a7d3d4c0..b7368e8886 100644 --- a/src/lib/international-telephone-codes.ts +++ b/src/lib/international-telephone-codes.ts @@ -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 -} diff --git a/src/screens/Settings/FindContactsSettings.tsx b/src/screens/Settings/FindContactsSettings.tsx index 22c107ab64..7e6abaf153 100644 --- a/src/screens/Settings/FindContactsSettings.tsx +++ b/src/screens/Settings/FindContactsSettings.tsx @@ -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 export function FindContactsSettingsScreen({}: Props) { const {_} = useLingui() - const hasInitiated = true + const {data, error, refetch} = useContactsSyncStatusQuery() return ( @@ -50,16 +62,27 @@ export function FindContactsSettingsScreen({}: Props) { {isNative ? ( - !hasInitiated ? ( - + data ? ( + !data.syncStatus ? ( + + ) : ( + + ) + ) : error ? ( + ) : ( - + + + ) ) : ( )} @@ -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 +}) { + 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) => { if (!moderationOpts) return null @@ -132,21 +170,30 @@ function Status() { ) }, - [numMatches, moderationOpts], + [numProfiles, moderationOpts], ) + + const onEndReached = () => { + if (!hasNextPage || isFetchingNextPage) return + fetchNextPage() + } + return ( + } - ListFooterComponent={} + ListFooterComponent={} + 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( + 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 ( @@ -281,7 +358,7 @@ function StatusFooter() { Contacts last uploaded on{' '} - {i18n.date(new Date(), { + {i18n.date(new Date(syncedAt), { dateStyle: 'long', })} @@ -291,9 +368,11 @@ function StatusFooter() {