Files
bsky-social-app/src/screens/Settings/FindContactsSettings.tsx
T

596 lines
17 KiB
TypeScript

import {useCallback, useEffect, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native'
import * as Contacts from 'expo-contacts'
import {type AppBskyContactDefs} from '@atproto/api'
import {type DidString} from '@atproto/syntax'
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
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 {useAppviewClient, usePdsClient, 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 {useDialogControl} from '#/components/Dialog'
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 {InviteFriendsDialog} from '#/features/inviteFriends'
import {app} from '#/lexicons'
import type * as bsky from '#/types/bsky'
import {bulkWriteFollows} from '../Onboarding/util'
type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'>
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 (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Find and invite friends</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
{IS_NATIVE ? (
data ? (
!data.syncStatus ? (
<Intro />
) : (
<SyncStatus info={data.syncStatus} refetchStatus={refetch} />
)
) : error ? (
<ErrorScreen
title={_(msg`Error getting the latest data.`)}
message={cleanError(error)}
onPressTryAgain={refetch}
/>
) : (
<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 import your contacts.`)}
/>
)}
</Layout.Screen>
)
}
function Intro() {
const gutter = useGutters(['base'])
const t = useTheme()
const {_} = useLingui()
const ax = useAnalytics()
const inviteFriendsControl = useDialogControl()
const {data: isAvailable, isSuccess} = useQuery({
queryKey: ['contacts-available'],
queryFn: async () => await Contacts.isAvailableAsync(),
})
return (
<Layout.Content contentContainerStyle={[gutter, a.gap_lg]}>
<ContactsHeroImage />
<Text style={[a.text_xl, a.font_bold]}>
<Trans>Find people you know</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
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.{' '}
<InlineLinkText
to={urls.website.blog.findFriendsAnnouncement}
label={_(
msg({
message: `Learn more about importing contacts`,
context: `english-only-resource`,
}),
)}
style={[a.text_md, a.leading_snug]}>
<Trans context="english-only-resource">Learn more</Trans>
</InlineLinkText>
</Trans>
</Text>
{isAvailable ? (
<Link
to={{screen: 'FindContactsFlow'}}
label={_(msg`Import contacts`)}
size="large"
color="primary"
style={[a.flex_1, a.justify_center]}>
<ButtonText>
<Trans>Import contacts</Trans>
</ButtonText>
</Link>
) : (
isSuccess && (
<Admonition type="error">
<Trans>
Contact sync is not available on this device, as the app is unable
to access your contacts.
</Trans>
</Admonition>
)
)}
<Button
label={_(msg`Share my profile`)}
size="large"
color="secondary"
onPress={() => {
ax.metric('invite:dialog:open', {logContext: 'FindContactsSettings'})
inviteFriendsControl.open()
}}
style={[a.flex_1, a.justify_center]}>
<ButtonText>
<Trans>Share my profile</Trans>
</ButtonText>
</Button>
<InviteFriendsDialog control={inviteFriendsControl} />
</Layout.Content>
)
}
function SyncStatus({
info,
refetchStatus,
}: {
info: AppBskyContactDefs.SyncStatus
refetchStatus: () => Promise<any>
}) {
const ax = useAnalytics()
const client = useAppviewClient()
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 client.call(app.bsky.contact.dismissMatch, {
subject: did as DidString,
})
},
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<bsky.profile.AnyProfileView>) => {
if (!moderationOpts) return null
return (
<MatchItem
profile={item}
isFirst={index === 0}
isLast={index === numProfiles - 1}
moderationOpts={moderationOpts}
dismissMatch={dismissMatch}
/>
)
},
[numProfiles, moderationOpts, dismissMatch],
)
const onEndReached = () => {
if (!hasNextPage || isFetchingNextPage) return
fetchNextPage()
}
return (
<List
data={profiles}
renderItem={renderItem}
ListHeaderComponent={
<StatusHeader
numMatches={info.matchesCount}
isPending={isPending}
isAnyUnfollowed={isAnyUnfollowed}
/>
}
ListFooterComponent={<StatusFooter syncedAt={info.syncedAt} />}
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 (
<View style={[a.px_xl]}>
<View
style={[
a.p_md,
a.border_t,
a.border_x,
t.atoms.border_contrast_high,
isFirst && [
a.curve_continuous,
{borderTopLeftRadius: tokens.borderRadius.lg},
{borderTopRightRadius: tokens.borderRadius.lg},
],
isLast && [
a.border_b,
a.curve_continuous,
{borderBottomLeftRadius: tokens.borderRadius.lg},
{borderBottomRightRadius: tokens.borderRadius.lg},
a.mb_sm,
],
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="FindContacts"
onFollow={() => ax.metric('contacts:settings:follow', {})}
/>
{!shadow.viewer?.following && (
<Button
color="secondary"
variant="ghost"
label={_(msg`Remove suggestion`)}
onPress={() => dismissMatch(profile.did)}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
hitSlop={8}>
<ButtonIcon icon={XIcon} />
</Button>
)}
</ProfileCard.Header>
</View>
</View>
)
}
function StatusHeader({
numMatches,
isPending,
isAnyUnfollowed,
}: {
numMatches: number
isPending: boolean
isAnyUnfollowed: boolean
}) {
const {_} = useLingui()
const ax = useAnalytics()
const pdsClient = usePdsClient()
const client = useAppviewClient()
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 client.call(app.bsky.contact.getMatches, {
limit: 100,
cursor,
})
cursor = page.cursor
for (const profile of page.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(pdsClient, client, 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 (
<View style={[a.w_full, a.py_3xl, a.align_center]}>
<Loader size="xl" />
</View>
)
}
return (
<View
style={[
a.pt_xl,
a.px_xl,
a.pb_md,
a.flex_row,
a.justify_between,
a.align_center,
]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={numMatches}
one="# contact found"
other="# contacts found"
/>
</Text>
{isAnyUnfollowed && (
<Button
label={_(msg`Follow all`)}
color="primary"
size="small"
variant="ghost"
onPress={() => onFollowAll()}
disabled={isFollowingAll || hasFollowedAll}
hitSlop={HITSLOP_10}
style={[a.px_0, a.py_0, a.rounded_0]}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}>
<ButtonText>
<Trans>Follow all</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
return null
}
function StatusFooter({syncedAt}: {syncedAt: string}) {
const {_, i18n} = useLingui()
const t = useTheme()
const ax = useAnalytics()
const client = useAppviewClient()
const queryClient = useQueryClient()
const {mutate: removeData, isPending} = useMutation({
mutationFn: async () => {
await client.call(app.bsky.contact.removeData, {})
},
onMutate: () => ax.metric('contacts:settings:removeData', {}),
onSuccess: () => {
Toast.show(_(msg`Contacts removed`))
queryClient.setQueryData<app.bsky.contact.getSyncStatus.$OutputBody>(
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]}>
<View style={[a.gap_xs, a.align_start]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Trans>Contacts imported</Trans>
</Text>
<View style={[a.gap_2xs]}>
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>We will notify you when we find your friends.</Trans>
</Text>
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Imported on{' '}
{i18n.date(new Date(syncedAt), {
dateStyle: 'long',
})}
</Trans>
</Text>
</View>
<Link
label={_(msg`Resync contacts`)}
to={{screen: 'FindContactsFlow'}}
onPress={() => {
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]}>
<ButtonIcon icon={ResyncIcon} />
<ButtonText>
<Trans>Resync contacts</Trans>
</ButtonText>
</Link>
</View>
<View style={[a.gap_xs, a.align_start]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Trans>Delete contacts</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Bluesky stores your contacts as encoded data. Removing your contacts
will immediately delete this data.
</Trans>
</Text>
<Button
label={_(msg`Remove all contacts`)}
onPress={() => removeData()}
size="small"
color="negative_subtle"
disabled={isPending}
style={[a.mt_xs]}>
<ButtonIcon icon={isPending ? Loader : TrashIcon} />
<ButtonText>
<Trans>Remove all contacts</Trans>
</ButtonText>
</Button>
</View>
</View>
)
}