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