import {useCallback, useEffect, useState} from 'react' import {type ListRenderItemInfo, View} from 'react-native' import * as Contacts from 'expo-contacts' import { type AppBskyContactDefs, type AppBskyContactGetSyncStatus, type ModerationOpts, } from '@atproto/api' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useIsFocused} from '@react-navigation/native' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {wait} from '#/lib/async/wait' import {HITSLOP_10, urls} from '#/lib/constants' import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' import { type AllNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import { updateProfileShadow, useProfileShadow, } from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { findContactsStatusQueryKey, optimisticRemoveMatch, useContactsMatchesQuery, useContactsSyncStatusQuery, } from '#/state/queries/find-contacts' import {useAgent, useSession} 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' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {ContactsHeroImage} from '#/components/contacts/components/HeroImage' import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ResyncIcon} from '#/components/icons/ArrowRotate' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 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 {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import type * as bsky from '#/types/bsky' import {bulkWriteFollows} from '../Onboarding/util' type Props = NativeStackScreenProps export function FindContactsSettingsScreen({}: Props) { const {_} = useLingui() const ax = useAnalytics() const {data, error, refetch} = useContactsSyncStatusQuery() const isFocused = useIsFocused() useEffect(() => { if (data && isFocused) { ax.metric('contacts:settings:presented', { hasPreviouslySynced: !!data.syncStatus, matchCount: data.syncStatus?.matchesCount, }) } }, [data, isFocused]) return ( Find Friends {IS_NATIVE ? ( data ? ( !data.syncStatus ? ( ) : ( ) ) : error ? ( ) : ( ) ) : ( )} ) } function Intro() { const gutter = useGutters(['base']) const t = useTheme() const {_} = useLingui() const {data: isAvailable, isSuccess} = useQuery({ queryKey: ['contacts-available'], queryFn: async () => await Contacts.isAvailableAsync(), }) return ( Find your friends on Bluesky by verifying your phone number and matching with your contacts. We protect your information and you control what happens next.{' '} Learn more {isAvailable ? ( Import contacts ) : ( isSuccess && ( Contact sync is not available on this device, as the app is unable to access your contacts. ) )} ) } function SyncStatus({ info, refetchStatus, }: { info: AppBskyContactDefs.SyncStatus refetchStatus: () => Promise }) { const ax = useAnalytics() const agent = useAgent() const queryClient = useQueryClient() const {_} = useLingui() const moderationOpts = useModerationOpts() const { data, isPending, hasNextPage, fetchNextPage, isFetchingNextPage, refetch: refetchMatches, } = useContactsMatchesQuery() const [isPTR, setIsPTR] = useState(false) const onRefresh = () => { setIsPTR(true) Promise.all([refetchStatus(), refetchMatches()]).finally(() => { setIsPTR(false) }) } const {mutate: dismissMatch} = useMutation({ mutationFn: async (did: string) => { await agent.app.bsky.contact.dismissMatch({subject: did}) }, onMutate: async (did: string) => { ax.metric('contacts:settings:dismiss', {}) optimisticRemoveMatch(queryClient, did) }, onError: err => { refetchMatches() if (isNetworkError(err)) { Toast.show( _( msg`Could not follow all matches - please check your network connection.`, ), {type: 'error'}, ) } else { logger.error('Failed to follow all matches', {safeMessage: err}) Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), { type: 'error', }) } }, }) const profiles = data?.pages?.flatMap(page => page.matches) ?? [] const numProfiles = profiles.length const isAnyUnfollowed = profiles.some(profile => !profile.viewer?.following) const renderItem = useCallback( ({item, index}: ListRenderItemInfo) => { if (!moderationOpts) return null return ( ) }, [numProfiles, moderationOpts, dismissMatch], ) const onEndReached = () => { if (!hasNextPage || isFetchingNextPage) return fetchNextPage() } return ( } ListFooterComponent={} onRefresh={onRefresh} refreshing={isPTR} onEndReached={onEndReached} /> ) } function MatchItem({ profile, isFirst, isLast, moderationOpts, dismissMatch, }: { profile: bsky.profile.AnyProfileView isFirst: boolean isLast: boolean moderationOpts: ModerationOpts dismissMatch: (did: string) => void }) { const t = useTheme() const {_} = useLingui() const ax = useAnalytics() const shadow = useProfileShadow(profile) return ( ax.metric('contacts:settings:follow', {})} /> {!shadow.viewer?.following && ( )} ) } function StatusHeader({ numMatches, isPending, isAnyUnfollowed, }: { numMatches: number isPending: boolean isAnyUnfollowed: boolean }) { const {_} = useLingui() const ax = useAnalytics() const agent = useAgent() const queryClient = useQueryClient() const {currentAccount} = useSession() const { mutate: onFollowAll, isPending: isFollowingAll, isSuccess: hasFollowedAll, } = useMutation({ mutationFn: async () => { const didsToFollow = [] let cursor: string | undefined do { const page = await agent.app.bsky.contact.getMatches({ limit: 100, cursor, }) cursor = page.data.cursor for (const profile of page.data.matches) { if ( profile.did !== currentAccount?.did && !isBlockedOrBlocking(profile) && !isMuted(profile) && !profile.viewer?.following ) { didsToFollow.push(profile.did) } } } while (cursor) ax.metric('contacts:settings:followAll', { followCount: didsToFollow.length, }) const uris = await wait(500, bulkWriteFollows(agent, didsToFollow)) for (const did of didsToFollow) { const uri = uris.get(did) updateProfileShadow(queryClient, did, { followingUri: uri, }) } }, onSuccess: () => { Toast.show(_(msg`Followed all matches`), {type: 'success'}) }, onError: err => { if (isNetworkError(err)) { Toast.show( _( msg`Could not follow all matches - please check your network connection.`, ), {type: 'error'}, ) } else { logger.error('Failed to follow all matches', {safeMessage: err}) Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), { type: 'error', }) } }, }) if (numMatches > 0) { if (isPending) { return ( ) } return ( {isAnyUnfollowed && ( )} ) } return null } function StatusFooter({syncedAt}: {syncedAt: string}) { const {_, i18n} = useLingui() const t = useTheme() const ax = useAnalytics() const agent = useAgent() const queryClient = useQueryClient() const {mutate: removeData, isPending} = useMutation({ mutationFn: async () => { await agent.app.bsky.contact.removeData({}) }, onMutate: () => ax.metric('contacts:settings: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 ( Contacts imported We will notify you when we find your friends. Imported on{' '} {i18n.date(new Date(syncedAt), { dateStyle: 'long', })} { const daysSinceLastSync = Math.floor( (Date.now() - new Date(syncedAt).getTime()) / (1000 * 60 * 60 * 24), ) ax.metric('contacts:settings:resync', { daysSinceLastSync, }) }} size="small" color="primary_subtle" style={[a.mt_xs]}> Resync contacts Delete contacts Bluesky stores your contacts as encoded data. Removing your contacts will immediately delete this data. ) }