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

Configure Feed

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

at post-text-option 562 lines 16 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 {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}