Bluesky app fork with some witchin' additions 馃挮
at linkat-integration 568 lines 17 kB view raw
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}