Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 569 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} 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}