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