this repo has no description
1import {useCallback, useEffect, useState} from 'react'
2import {type ListRenderItemInfo, View} from 'react-native'
3import * as Contacts from 'expo-contacts'
4import {
5 type AppBskyContactDefs,
6 type AppBskyContactGetSyncStatus,
7 type ModerationOpts,
8} from '@atproto/api'
9import {msg} from '@lingui/core/macro'
10import {useLingui} from '@lingui/react'
11import {Plural, Trans} from '@lingui/react/macro'
12import {useIsFocused} from '@react-navigation/native'
13import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
14
15import {wait} from '#/lib/async/wait'
16import {HITSLOP_10, urls} from '#/lib/constants'
17import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
18import {
19 type AllNavigatorParams,
20 type NativeStackScreenProps,
21} from '#/lib/routes/types'
22import {cleanError, isNetworkError} from '#/lib/strings/errors'
23import {logger} from '#/logger'
24import {
25 updateProfileShadow,
26 useProfileShadow,
27} from '#/state/cache/profile-shadow'
28import {useModerationOpts} from '#/state/preferences/moderation-opts'
29import {
30 findContactsStatusQueryKey,
31 optimisticRemoveMatch,
32 useContactsMatchesQuery,
33 useContactsSyncStatusQuery,
34} from '#/state/queries/find-contacts'
35import {useAgent, useSession} from '#/state/session'
36import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
37import {List} from '#/view/com/util/List'
38import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
39import {Admonition} from '#/components/Admonition'
40import {Button, ButtonIcon, ButtonText} from '#/components/Button'
41import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
42import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ResyncIcon} from '#/components/icons/ArrowRotate'
43import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
44import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
45import * as Layout from '#/components/Layout'
46import {InlineLinkText, Link} from '#/components/Link'
47import {Loader} from '#/components/Loader'
48import * as ProfileCard from '#/components/ProfileCard'
49import * as Toast from '#/components/Toast'
50import {Text} from '#/components/Typography'
51import {useAnalytics} from '#/analytics'
52import {IS_NATIVE} from '#/env'
53import type * as bsky from '#/types/bsky'
54import {bulkWriteFollows} from '../Onboarding/util'
55
56type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'>
57export function FindContactsSettingsScreen({}: Props) {
58 const {_} = useLingui()
59 const ax = useAnalytics()
60
61 const {data, error, refetch} = useContactsSyncStatusQuery()
62
63 const isFocused = useIsFocused()
64 useEffect(() => {
65 if (data && isFocused) {
66 ax.metric('contacts:settings:presented', {
67 hasPreviouslySynced: !!data.syncStatus,
68 matchCount: data.syncStatus?.matchesCount,
69 })
70 }
71 }, [data, isFocused])
72
73 return (
74 <Layout.Screen>
75 <Layout.Header.Outer>
76 <Layout.Header.BackButton />
77 <Layout.Header.Content>
78 <Layout.Header.TitleText>
79 <Trans>Find Friends</Trans>
80 </Layout.Header.TitleText>
81 </Layout.Header.Content>
82 <Layout.Header.Slot />
83 </Layout.Header.Outer>
84 {IS_NATIVE ? (
85 data ? (
86 !data.syncStatus ? (
87 <Intro />
88 ) : (
89 <SyncStatus info={data.syncStatus} refetchStatus={refetch} />
90 )
91 ) : error ? (
92 <ErrorScreen
93 title={_(msg`Error getting the latest data.`)}
94 message={cleanError(error)}
95 onPressTryAgain={refetch}
96 />
97 ) : (
98 <View style={[a.flex_1, a.justify_center, a.align_center]}>
99 <Loader size="xl" />
100 </View>
101 )
102 ) : (
103 <ErrorScreen
104 title={_(msg`Not available on this platform.`)}
105 message={_(msg`Please use the native app to import your contacts.`)}
106 />
107 )}
108 </Layout.Screen>
109 )
110}
111
112function Intro() {
113 const gutter = useGutters(['base'])
114 const t = useTheme()
115 const {_} = useLingui()
116
117 const {data: isAvailable, isSuccess} = useQuery({
118 queryKey: ['contacts-available'],
119 queryFn: async () => await Contacts.isAvailableAsync(),
120 })
121
122 return (
123 <Layout.Content contentContainerStyle={[gutter, a.gap_lg]}>
124 <ContactsHeroImage />
125 <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
126 <Trans>
127 Find your friends on Bluesky by verifying your phone number and
128 matching with your contacts. We protect your information and you
129 control what happens next.{' '}
130 <InlineLinkText
131 to={urls.website.blog.findFriendsAnnouncement}
132 label={_(
133 msg({
134 message: `Learn more about importing contacts`,
135 context: `english-only-resource`,
136 }),
137 )}
138 style={[a.text_md, a.leading_snug]}>
139 <Trans context="english-only-resource">Learn more</Trans>
140 </InlineLinkText>
141 </Trans>
142 </Text>
143 {isAvailable ? (
144 <Link
145 to={{screen: 'FindContactsFlow'}}
146 label={_(msg`Import contacts`)}
147 size="large"
148 color="primary"
149 style={[a.flex_1, a.justify_center]}>
150 <ButtonText>
151 <Trans>Import contacts</Trans>
152 </ButtonText>
153 </Link>
154 ) : (
155 isSuccess && (
156 <Admonition type="error">
157 <Trans>
158 Contact sync is not available on this device, as the app is unable
159 to access your contacts.
160 </Trans>
161 </Admonition>
162 )
163 )}
164 </Layout.Content>
165 )
166}
167
168function SyncStatus({
169 info,
170 refetchStatus,
171}: {
172 info: AppBskyContactDefs.SyncStatus
173 refetchStatus: () => Promise<any>
174}) {
175 const ax = useAnalytics()
176 const agent = useAgent()
177 const queryClient = useQueryClient()
178 const {_} = useLingui()
179 const moderationOpts = useModerationOpts()
180
181 const {
182 data,
183 isPending,
184 hasNextPage,
185 fetchNextPage,
186 isFetchingNextPage,
187 refetch: refetchMatches,
188 } = useContactsMatchesQuery()
189
190 const [isPTR, setIsPTR] = useState(false)
191
192 const onRefresh = () => {
193 setIsPTR(true)
194 Promise.all([refetchStatus(), refetchMatches()]).finally(() => {
195 setIsPTR(false)
196 })
197 }
198
199 const {mutate: dismissMatch} = useMutation({
200 mutationFn: async (did: string) => {
201 await agent.app.bsky.contact.dismissMatch({subject: did})
202 },
203 onMutate: async (did: string) => {
204 ax.metric('contacts:settings:dismiss', {})
205 optimisticRemoveMatch(queryClient, did)
206 },
207 onError: err => {
208 refetchMatches()
209 if (isNetworkError(err)) {
210 Toast.show(
211 _(
212 msg`Could not follow all matches - please check your network connection.`,
213 ),
214 {type: 'error'},
215 )
216 } else {
217 logger.error('Failed to follow all matches', {safeMessage: err})
218 Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), {
219 type: 'error',
220 })
221 }
222 },
223 })
224
225 const profiles = data?.pages?.flatMap(page => page.matches) ?? []
226
227 const numProfiles = profiles.length
228 const isAnyUnfollowed = profiles.some(profile => !profile.viewer?.following)
229
230 const renderItem = useCallback(
231 ({item, index}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
232 if (!moderationOpts) return null
233 return (
234 <MatchItem
235 profile={item}
236 isFirst={index === 0}
237 isLast={index === numProfiles - 1}
238 moderationOpts={moderationOpts}
239 dismissMatch={dismissMatch}
240 />
241 )
242 },
243 [numProfiles, moderationOpts, dismissMatch],
244 )
245
246 const onEndReached = () => {
247 if (!hasNextPage || isFetchingNextPage) return
248 fetchNextPage()
249 }
250
251 return (
252 <List
253 data={profiles}
254 renderItem={renderItem}
255 ListHeaderComponent={
256 <StatusHeader
257 numMatches={info.matchesCount}
258 isPending={isPending}
259 isAnyUnfollowed={isAnyUnfollowed}
260 />
261 }
262 ListFooterComponent={<StatusFooter syncedAt={info.syncedAt} />}
263 onRefresh={onRefresh}
264 refreshing={isPTR}
265 onEndReached={onEndReached}
266 />
267 )
268}
269
270function MatchItem({
271 profile,
272 isFirst,
273 isLast,
274 moderationOpts,
275 dismissMatch,
276}: {
277 profile: bsky.profile.AnyProfileView
278 isFirst: boolean
279 isLast: boolean
280 moderationOpts: ModerationOpts
281 dismissMatch: (did: string) => void
282}) {
283 const t = useTheme()
284 const {_} = useLingui()
285 const ax = useAnalytics()
286 const shadow = useProfileShadow(profile)
287
288 return (
289 <View style={[a.px_xl]}>
290 <View
291 style={[
292 a.p_md,
293 a.border_t,
294 a.border_x,
295 t.atoms.border_contrast_high,
296 isFirst && [
297 a.curve_continuous,
298 {borderTopLeftRadius: tokens.borderRadius.lg},
299 {borderTopRightRadius: tokens.borderRadius.lg},
300 ],
301 isLast && [
302 a.border_b,
303 a.curve_continuous,
304 {borderBottomLeftRadius: tokens.borderRadius.lg},
305 {borderBottomRightRadius: tokens.borderRadius.lg},
306 a.mb_sm,
307 ],
308 ]}>
309 <ProfileCard.Header>
310 <ProfileCard.Avatar
311 profile={profile}
312 moderationOpts={moderationOpts}
313 />
314 <ProfileCard.NameAndHandle
315 profile={profile}
316 moderationOpts={moderationOpts}
317 />
318 <ProfileCard.FollowButton
319 profile={profile}
320 moderationOpts={moderationOpts}
321 logContext="FindContacts"
322 onFollow={() => ax.metric('contacts:settings:follow', {})}
323 />
324 {!shadow.viewer?.following && (
325 <Button
326 color="secondary"
327 variant="ghost"
328 label={_(msg`Remove suggestion`)}
329 onPress={() => dismissMatch(profile.did)}
330 hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
331 hitSlop={8}>
332 <ButtonIcon icon={XIcon} />
333 </Button>
334 )}
335 </ProfileCard.Header>
336 </View>
337 </View>
338 )
339}
340
341function StatusHeader({
342 numMatches,
343 isPending,
344 isAnyUnfollowed,
345}: {
346 numMatches: number
347 isPending: boolean
348 isAnyUnfollowed: boolean
349}) {
350 const {_} = useLingui()
351 const ax = useAnalytics()
352 const agent = useAgent()
353 const queryClient = useQueryClient()
354 const {currentAccount} = useSession()
355
356 const {
357 mutate: onFollowAll,
358 isPending: isFollowingAll,
359 isSuccess: hasFollowedAll,
360 } = useMutation({
361 mutationFn: async () => {
362 const didsToFollow = []
363
364 let cursor: string | undefined
365 do {
366 const page = await agent.app.bsky.contact.getMatches({
367 limit: 100,
368 cursor,
369 })
370 cursor = page.data.cursor
371 for (const profile of page.data.matches) {
372 if (
373 profile.did !== currentAccount?.did &&
374 !isBlockedOrBlocking(profile) &&
375 !isMuted(profile) &&
376 !profile.viewer?.following
377 ) {
378 didsToFollow.push(profile.did)
379 }
380 }
381 } while (cursor)
382
383 ax.metric('contacts:settings:followAll', {
384 followCount: didsToFollow.length,
385 })
386
387 const uris = await wait(500, bulkWriteFollows(agent, didsToFollow))
388
389 for (const did of didsToFollow) {
390 const uri = uris.get(did)
391 updateProfileShadow(queryClient, did, {
392 followingUri: uri,
393 })
394 }
395 },
396 onSuccess: () => {
397 Toast.show(_(msg`Followed all matches`), {type: 'success'})
398 },
399 onError: err => {
400 if (isNetworkError(err)) {
401 Toast.show(
402 _(
403 msg`Could not follow all matches - please check your network connection.`,
404 ),
405 {type: 'error'},
406 )
407 } else {
408 logger.error('Failed to follow all matches', {safeMessage: err})
409 Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), {
410 type: 'error',
411 })
412 }
413 },
414 })
415
416 if (numMatches > 0) {
417 if (isPending) {
418 return (
419 <View style={[a.w_full, a.py_3xl, a.align_center]}>
420 <Loader size="xl" />
421 </View>
422 )
423 }
424
425 return (
426 <View
427 style={[
428 a.pt_xl,
429 a.px_xl,
430 a.pb_md,
431 a.flex_row,
432 a.justify_between,
433 a.align_center,
434 ]}>
435 <Text style={[a.text_md, a.font_semi_bold]}>
436 <Plural
437 value={numMatches}
438 one="# contact found"
439 other="# contacts found"
440 />
441 </Text>
442 {isAnyUnfollowed && (
443 <Button
444 label={_(msg`Follow all`)}
445 color="primary"
446 size="small"
447 variant="ghost"
448 onPress={() => onFollowAll()}
449 disabled={isFollowingAll || hasFollowedAll}
450 hitSlop={HITSLOP_10}
451 style={[a.px_0, a.py_0, a.rounded_0]}
452 hoverStyle={[a.bg_transparent, {opacity: 0.5}]}>
453 <ButtonText>
454 <Trans>Follow all</Trans>
455 </ButtonText>
456 </Button>
457 )}
458 </View>
459 )
460 }
461
462 return null
463}
464
465function StatusFooter({syncedAt}: {syncedAt: string}) {
466 const {_, i18n} = useLingui()
467 const t = useTheme()
468 const ax = useAnalytics()
469 const agent = useAgent()
470 const queryClient = useQueryClient()
471
472 const {mutate: removeData, isPending} = useMutation({
473 mutationFn: async () => {
474 await agent.app.bsky.contact.removeData({})
475 },
476 onMutate: () => ax.metric('contacts:settings:removeData', {}),
477 onSuccess: () => {
478 Toast.show(_(msg`Contacts removed`))
479 queryClient.setQueryData<AppBskyContactGetSyncStatus.OutputSchema>(
480 findContactsStatusQueryKey,
481 {syncStatus: undefined},
482 )
483 },
484 onError: err => {
485 if (isNetworkError(err)) {
486 Toast.show(
487 _(
488 msg`Failed to remove data due to a network error, please check your internet connection.`,
489 ),
490 {type: 'error'},
491 )
492 } else {
493 logger.error('Remove data failed', {safeMessage: err})
494 Toast.show(_(msg`Failed to remove data. ${cleanError(err)}`), {
495 type: 'error',
496 })
497 }
498 },
499 })
500
501 return (
502 <View style={[a.px_xl, a.py_xl, a.gap_4xl]}>
503 <View style={[a.gap_xs, a.align_start]}>
504 <Text style={[a.text_md, a.font_semi_bold]}>
505 <Trans>Contacts imported</Trans>
506 </Text>
507 <View style={[a.gap_2xs]}>
508 <Text
509 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
510 <Trans>We will notify you when we find your friends.</Trans>
511 </Text>
512 <Text
513 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
514 <Trans>
515 Imported on{' '}
516 {i18n.date(new Date(syncedAt), {
517 dateStyle: 'long',
518 })}
519 </Trans>
520 </Text>
521 </View>
522 <Link
523 label={_(msg`Resync contacts`)}
524 to={{screen: 'FindContactsFlow'}}
525 onPress={() => {
526 const daysSinceLastSync = Math.floor(
527 (Date.now() - new Date(syncedAt).getTime()) /
528 (1000 * 60 * 60 * 24),
529 )
530 ax.metric('contacts:settings:resync', {
531 daysSinceLastSync,
532 })
533 }}
534 size="small"
535 color="primary_subtle"
536 style={[a.mt_xs]}>
537 <ButtonIcon icon={ResyncIcon} />
538 <ButtonText>
539 <Trans>Resync contacts</Trans>
540 </ButtonText>
541 </Link>
542 </View>
543
544 <View style={[a.gap_xs, a.align_start]}>
545 <Text style={[a.text_md, a.font_semi_bold]}>
546 <Trans>Delete contacts</Trans>
547 </Text>
548 <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
549 <Trans>
550 Bluesky stores your contacts as encoded data. Removing your contacts
551 will immediately delete this data.
552 </Trans>
553 </Text>
554 <Button
555 label={_(msg`Remove all contacts`)}
556 onPress={() => removeData()}
557 size="small"
558 color="negative_subtle"
559 disabled={isPending}
560 style={[a.mt_xs]}>
561 <ButtonIcon icon={isPending ? Loader : TrashIcon} />
562 <ButtonText>
563 <Trans>Remove all contacts</Trans>
564 </ButtonText>
565 </Button>
566 </View>
567 </View>
568 )
569}