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 685 lines 20 kB view raw
1import {useCallback, useMemo, useRef, useState} from 'react' 2import {View} from 'react-native' 3import {useSafeAreaInsets} from 'react-native-safe-area-context' 4import * as SMS from 'expo-sms' 5import {type ModerationOpts} from '@atproto/api' 6import {msg} from '@lingui/core/macro' 7import {useLingui} from '@lingui/react' 8import {Plural, Trans} from '@lingui/react/macro' 9import {useMutation, useQueryClient} from '@tanstack/react-query' 10 11import {wait} from '#/lib/async/wait' 12import {cleanError, isNetworkError} from '#/lib/strings/errors' 13import {logger} from '#/logger' 14import { 15 updateProfileShadow, 16 useProfileShadow, 17} from '#/state/cache/profile-shadow' 18import {useModerationOpts} from '#/state/preferences/moderation-opts' 19import { 20 optimisticRemoveMatch, 21 useMatchesPassthroughQuery, 22} from '#/state/queries/find-contacts' 23import {useAgent, useSession} from '#/state/session' 24import {List, type ListMethods} from '#/view/com/util/List' 25import {UserAvatar} from '#/view/com/util/UserAvatar' 26import {OnboardingPosition} from '#/screens/Onboarding/Layout' 27import {bulkWriteFollows} from '#/screens/Onboarding/util' 28import {atoms as a, tokens, useGutters, useTheme} from '#/alf' 29import {Button, ButtonIcon, ButtonText} from '#/components/Button' 30import {SearchInput} from '#/components/forms/SearchInput' 31import {useInteractionState} from '#/components/hooks/useInteractionState' 32import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 33import {MagnifyingGlassX_Stroke2_Corner0_Rounded_Large as SearchFailedIcon} from '#/components/icons/MagnifyingGlass' 34import {PersonX_Stroke2_Corner0_Rounded_Large as PersonXIcon} from '#/components/icons/Person' 35import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 36import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 37import * as Layout from '#/components/Layout' 38import {ListFooter} from '#/components/Lists' 39import {Loader} from '#/components/Loader' 40import * as ProfileCard from '#/components/ProfileCard' 41import * as Toast from '#/components/Toast' 42import {Text} from '#/components/Typography' 43import {useAnalytics} from '#/analytics' 44import type * as bsky from '#/types/bsky' 45import {InviteInfo} from '../components/InviteInfo' 46import {type Action, type Contact, type Match, type State} from '../state' 47 48type Item = 49 | { 50 type: 'matches header' 51 count: number 52 } 53 | { 54 type: 'match' 55 match: Match 56 } 57 | { 58 type: 'contacts header' 59 } 60 | { 61 type: 'contact' 62 contact: Contact 63 } 64 | { 65 type: 'no matches header' 66 } 67 | { 68 type: 'search empty state' 69 query: string 70 } 71 | { 72 type: 'totally empty state' 73 } 74 75export function ViewMatches({ 76 state, 77 dispatch, 78 context, 79 onNext, 80}: { 81 state: Extract<State, {step: '4: view matches'}> 82 dispatch: React.ActionDispatch<[Action]> 83 context: 'Onboarding' | 'Standalone' 84 onNext: () => void 85}) { 86 const t = useTheme() 87 const {_} = useLingui() 88 const ax = useAnalytics() 89 const gutter = useGutters([0, 'wide']) 90 const moderationOpts = useModerationOpts() 91 const queryClient = useQueryClient() 92 const agent = useAgent() 93 const insets = useSafeAreaInsets() 94 const listRef = useRef<ListMethods>(null) 95 96 const [search, setSearch] = useState('') 97 const { 98 state: searchFocused, 99 onIn: onFocus, 100 onOut: onBlur, 101 } = useInteractionState() 102 103 // HACK: Although we already have the match data, we need to pass it through 104 // a query to get it into the shadow state 105 const allMatches = useMatchesPassthroughQuery(state.matches) 106 const matches = allMatches.filter( 107 match => !state.dismissedMatches.includes(match.profile.did), 108 ) 109 110 const followableDids = matches.map(match => match.profile.did) 111 const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0) 112 113 const cumulativeFollowCount = useRef(0) 114 const onFollow = useCallback(() => { 115 ax.metric('contacts:matches:follow', {entryPoint: context}) 116 cumulativeFollowCount.current += 1 117 }, [ax, context]) 118 119 const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 120 mutationFn: async () => { 121 for (const did of followableDids) { 122 updateProfileShadow(queryClient, did, { 123 followingUri: 'pending', 124 }) 125 } 126 127 const uris = await wait(500, bulkWriteFollows(agent, followableDids)) 128 129 for (const did of followableDids) { 130 const uri = uris.get(did) 131 updateProfileShadow(queryClient, did, { 132 followingUri: uri, 133 }) 134 } 135 return followableDids 136 }, 137 onMutate: () => 138 ax.metric('contacts:matches:followAll', { 139 followCount: followableDids.length, 140 entryPoint: context, 141 }), 142 onSuccess: () => { 143 setDidFollowAll(true) 144 Toast.show(_(msg`All friends followed!`), {type: 'success'}) 145 cumulativeFollowCount.current += followableDids.length 146 }, 147 onError: _err => { 148 Toast.show(_(msg`Failed to follow all your friends, please try again`), { 149 type: 'error', 150 }) 151 for (const did of followableDids) { 152 updateProfileShadow(queryClient, did, { 153 followingUri: undefined, 154 }) 155 } 156 }, 157 }) 158 159 const items = useMemo(() => { 160 const all: Item[] = [] 161 162 if (searchFocused || search.length > 0) { 163 for (const match of matches) { 164 if ( 165 search.length === 0 || 166 (match.profile.displayName ?? '') 167 .toLocaleLowerCase() 168 .includes(search.toLocaleLowerCase()) || 169 match.profile.handle 170 .toLocaleLowerCase() 171 .includes(search.toLocaleLowerCase()) 172 ) { 173 all.push({type: 'match', match}) 174 } 175 } 176 177 for (const contact of state.contacts) { 178 if ( 179 search.length === 0 || 180 [contact.firstName, contact.lastName] 181 .filter(Boolean) 182 .join(' ') 183 .toLocaleLowerCase() 184 .includes(search.toLocaleLowerCase()) 185 ) { 186 all.push({type: 'contact', contact}) 187 } 188 } 189 190 if (all.length === 0) { 191 all.push({type: 'search empty state', query: search}) 192 } 193 } else { 194 if (matches.length > 0) { 195 all.push({type: 'matches header', count: matches.length}) 196 for (const match of matches) { 197 all.push({type: 'match', match}) 198 } 199 200 if (state.contacts.length > 0) { 201 all.push({type: 'contacts header'}) 202 } 203 } else if (state.contacts.length > 0) { 204 all.push({type: 'no matches header'}) 205 } 206 207 for (const contact of state.contacts) { 208 all.push({type: 'contact', contact}) 209 } 210 211 if (all.length === 0) { 212 all.push({type: 'totally empty state'}) 213 } 214 } 215 216 return all 217 }, [matches, state.contacts, search, searchFocused]) 218 219 const {mutate: dismissMatch} = useMutation({ 220 mutationFn: async (did: string) => { 221 await agent.app.bsky.contact.dismissMatch({subject: did}) 222 }, 223 onMutate: did => { 224 ax.metric('contacts:matches:dismiss', {entryPoint: context}) 225 dispatch({type: 'DISMISS_MATCH', payload: {did}}) 226 }, 227 onSuccess: (_res, did) => { 228 // for the other screen 229 optimisticRemoveMatch(queryClient, did) 230 }, 231 onError: (err, did) => { 232 dispatch({type: 'DISMISS_MATCH_FAILED', payload: {did}}) 233 if (isNetworkError(err)) { 234 Toast.show( 235 _( 236 msg`Failed to hide suggestion, please check your internet connection`, 237 ), 238 {type: 'error'}, 239 ) 240 } else { 241 logger.error('Dismissing match failed', {safeMessage: err}) 242 Toast.show( 243 _(msg`An error occurred while hiding suggestion. ${cleanError(err)}`), 244 {type: 'error'}, 245 ) 246 } 247 }, 248 }) 249 250 const renderItem = ({item}: {item: Item}) => { 251 switch (item.type) { 252 case 'match': 253 return ( 254 <MatchItem 255 profile={item.match.profile} 256 contact={item.match.contact} 257 moderationOpts={moderationOpts} 258 onRemoveSuggestion={dismissMatch} 259 onFollow={onFollow} 260 /> 261 ) 262 case 'contact': 263 return <ContactItem contact={item.contact} context={context} /> 264 case 'matches header': 265 return ( 266 <Header 267 titleText={ 268 <Plural 269 value={item.count} 270 one="# friend found!" 271 other="# friends found!" 272 /> 273 }> 274 {item.count > 1 && ( 275 <Button 276 label={_(msg`Follow all`)} 277 size="small" 278 color="primary_subtle" 279 onPress={() => followAll()} 280 disabled={isFollowingAll || didFollowAll}> 281 <ButtonIcon 282 icon={ 283 isFollowingAll 284 ? Loader 285 : !didFollowAll 286 ? PlusIcon 287 : CheckIcon 288 } 289 /> 290 <ButtonText> 291 <Trans>Follow all</Trans> 292 </ButtonText> 293 </Button> 294 )} 295 </Header> 296 ) 297 case 'contacts header': 298 return ( 299 <Header 300 titleText={ 301 <Trans> 302 Invite friends{' '} 303 <InviteInfo iconStyle={t.atoms.text} iconOffset={1} /> 304 </Trans> 305 } 306 hasContentAbove 307 /> 308 ) 309 case 'no matches header': 310 return ( 311 <Header 312 titleText={_(msg`You got here first`)} 313 largeTitle 314 subtitleText={ 315 <Trans> 316 Bluesky is more fun with friends. Do you want to invite some of 317 yours?{' '} 318 <InviteInfo 319 iconStyle={t.atoms.text_contrast_medium} 320 iconOffset={2} 321 /> 322 </Trans> 323 } 324 /> 325 ) 326 case 'search empty state': 327 return <SearchEmptyState query={item.query} /> 328 case 'totally empty state': 329 return <TotallyEmptyState /> 330 } 331 } 332 333 const isSearchEmpty = items?.[0]?.type === 'search empty state' 334 const isTotallyEmpty = items?.[0]?.type === 'totally empty state' 335 336 const isEmpty = isSearchEmpty || isTotallyEmpty 337 338 return ( 339 <View style={[a.h_full]}> 340 {context === 'Standalone' && ( 341 <Layout.Header.Outer noBottomBorder> 342 <Layout.Header.BackButton /> 343 <Layout.Header.Content /> 344 <Layout.Header.Slot /> 345 </Layout.Header.Outer> 346 )} 347 {!isTotallyEmpty && ( 348 <View 349 style={[ 350 gutter, 351 a.mb_md, 352 context === 'Onboarding' && [a.mt_sm, a.gap_sm], 353 ]}> 354 {context === 'Onboarding' && <OnboardingPosition />} 355 <SearchInput 356 placeholder={_(msg`Search contacts`)} 357 value={search} 358 onFocus={() => { 359 onFocus() 360 listRef.current?.scrollToOffset({offset: 0, animated: false}) 361 }} 362 onBlur={() => { 363 onBlur() 364 listRef.current?.scrollToOffset({offset: 0, animated: false}) 365 }} 366 onChangeText={text => { 367 setSearch(text) 368 listRef.current?.scrollToOffset({offset: 0, animated: false}) 369 }} 370 onClearText={() => setSearch('')} 371 /> 372 </View> 373 )} 374 <List 375 ref={listRef} 376 data={items} 377 renderItem={renderItem} 378 ListFooterComponent={!isEmpty ? <ListFooter height={20} /> : null} 379 keyExtractor={keyExtractor} 380 keyboardDismissMode="interactive" 381 automaticallyAdjustKeyboardInsets 382 /> 383 <View 384 style={[ 385 t.atoms.bg, 386 t.atoms.border_contrast_low, 387 a.border_t, 388 a.align_center, 389 a.align_stretch, 390 gutter, 391 a.pt_md, 392 {paddingBottom: insets.bottom + tokens.space.md}, 393 ]}> 394 <Button 395 label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)} 396 onPress={() => { 397 if (context === 'Onboarding') { 398 ax.metric('onboarding:contacts:nextPressed', { 399 matchCount: allMatches.length, 400 followCount: cumulativeFollowCount.current, 401 dismissedMatchCount: state.dismissedMatches.length, 402 }) 403 } 404 onNext() 405 }} 406 size="large" 407 color="primary"> 408 <ButtonText> 409 {context === 'Onboarding' ? ( 410 <Trans>Next</Trans> 411 ) : ( 412 <Trans>Done</Trans> 413 )} 414 </ButtonText> 415 </Button> 416 </View> 417 </View> 418 ) 419} 420 421function keyExtractor(item: Item) { 422 switch (item.type) { 423 case 'contact': 424 return item.contact.id 425 case 'match': 426 return item.match.profile.did 427 default: 428 return item.type 429 } 430} 431 432function MatchItem({ 433 profile, 434 contact, 435 moderationOpts, 436 onRemoveSuggestion, 437 onFollow, 438}: { 439 profile: bsky.profile.AnyProfileView 440 contact?: Contact 441 moderationOpts?: ModerationOpts 442 onRemoveSuggestion: (did: string) => void 443 onFollow: () => void 444}) { 445 const gutter = useGutters([0, 'wide']) 446 const t = useTheme() 447 const {_} = useLingui() 448 const shadow = useProfileShadow(profile) 449 450 const contactName = useMemo(() => { 451 if (!contact) return null 452 453 const name = contact.name ?? contact.firstName ?? contact.lastName 454 if (name) return _(msg`Your contact ${name}`) 455 const phone = 456 contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0] 457 if (phone?.number) return phone.number 458 return null 459 }, [contact, _]) 460 461 if (!moderationOpts) return null 462 463 return ( 464 <View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}> 465 <ProfileCard.Header> 466 <ProfileCard.Avatar 467 profile={profile} 468 moderationOpts={moderationOpts} 469 size={48} 470 /> 471 <View style={[a.flex_1]}> 472 <ProfileCard.Name 473 profile={profile} 474 moderationOpts={moderationOpts} 475 textStyle={[a.leading_tight]} 476 /> 477 <ProfileCard.Handle 478 profile={profile} 479 textStyle={[contactName && a.text_xs]} 480 /> 481 {contactName && ( 482 <Text 483 emoji 484 style={[a.leading_snug, t.atoms.text_contrast_medium, a.text_xs]} 485 numberOfLines={1}> 486 {contactName} 487 </Text> 488 )} 489 </View> 490 <ProfileCard.FollowButton 491 profile={profile} 492 moderationOpts={moderationOpts} 493 logContext="FindContacts" 494 onFollow={onFollow} 495 /> 496 {!shadow.viewer?.following && ( 497 <Button 498 color="secondary" 499 variant="ghost" 500 label={_(msg`Remove suggestion`)} 501 onPress={() => onRemoveSuggestion(profile.did)} 502 hoverStyle={[a.bg_transparent, {opacity: 0.5}]} 503 hitSlop={8}> 504 <ButtonIcon icon={XIcon} /> 505 </Button> 506 )} 507 </ProfileCard.Header> 508 </View> 509 ) 510} 511 512function ContactItem({ 513 contact, 514 context, 515}: { 516 contact: Contact 517 context: 'Onboarding' | 'Standalone' 518}) { 519 const gutter = useGutters([0, 'wide']) 520 const t = useTheme() 521 const {_} = useLingui() 522 const ax = useAnalytics() 523 const {currentAccount} = useSession() 524 525 const name = contact.name ?? contact.firstName ?? contact.lastName 526 const phone = 527 contact.phoneNumbers?.find(phone => phone.isPrimary) ?? 528 contact.phoneNumbers?.[0] 529 const phoneNumber = phone?.number 530 531 return ( 532 <View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}> 533 <ProfileCard.Header> 534 {contact.image ? ( 535 <UserAvatar size={40} avatar={contact.image.uri} type="user" /> 536 ) : ( 537 <View 538 style={[ 539 {width: 40, height: 40}, 540 a.rounded_full, 541 a.justify_center, 542 a.align_center, 543 t.atoms.bg_contrast_400, 544 ]}> 545 <Text 546 style={[ 547 a.text_lg, 548 a.font_semi_bold, 549 {color: t.palette.contrast_0}, 550 ]}> 551 {name?.[0]?.toLocaleUpperCase()} 552 </Text> 553 </View> 554 )} 555 <Text 556 style={[ 557 a.flex_1, 558 a.text_md, 559 a.font_medium, 560 !name && [t.atoms.text_contrast_medium, a.italic], 561 ]} 562 numberOfLines={2}> 563 {name ?? <Trans>No name</Trans>} 564 </Text> 565 {phoneNumber && currentAccount && ( 566 <Button 567 label={_(msg`Invite ${name} to join Bluesky`)} 568 color="secondary" 569 size="small" 570 onPress={async () => { 571 ax.metric('contacts:matches:invite', { 572 entryPoint: context, 573 }) 574 try { 575 await SMS.sendSMSAsync( 576 [phoneNumber], 577 _( 578 msg`I'm on Bluesky as ${currentAccount.handle} - come find me! https://bsky.app/download`, 579 ), 580 ) 581 } catch (err) { 582 Toast.show(_(msg`Failed to launch SMS app`), {type: 'error'}) 583 logger.error('Could not launch SMS', {safeMessage: err}) 584 } 585 }}> 586 <ButtonText> 587 <Trans>Invite</Trans> 588 </ButtonText> 589 </Button> 590 )} 591 </ProfileCard.Header> 592 </View> 593 ) 594} 595 596function Header({ 597 titleText, 598 largeTitle, 599 subtitleText, 600 children, 601 hasContentAbove, 602}: { 603 titleText: React.ReactNode 604 largeTitle?: boolean 605 subtitleText?: React.ReactNode 606 children?: React.ReactNode 607 hasContentAbove?: boolean 608}) { 609 const gutter = useGutters([0, 'wide']) 610 const t = useTheme() 611 612 return ( 613 <View 614 style={[ 615 gutter, 616 a.pb_md, 617 a.gap_sm, 618 hasContentAbove 619 ? [a.pt_4xl, a.border_t, t.atoms.border_contrast_low] 620 : a.pt_md, 621 ]}> 622 <View style={[a.flex_row, a.align_center, a.justify_between]}> 623 <Text style={[largeTitle ? a.text_3xl : a.text_xl, a.font_bold]}> 624 {titleText} 625 </Text> 626 {children} 627 </View> 628 {subtitleText && ( 629 <Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}> 630 {subtitleText} 631 </Text> 632 )} 633 </View> 634 ) 635} 636 637function SearchEmptyState({query}: {query: string}) { 638 const t = useTheme() 639 640 return ( 641 <View 642 style={[ 643 a.flex_1, 644 a.flex_col, 645 a.align_center, 646 a.justify_center, 647 a.gap_lg, 648 a.pt_5xl, 649 a.px_5xl, 650 ]}> 651 <SearchFailedIcon width={64} style={[t.atoms.text_contrast_low]} /> 652 <Text 653 style={[ 654 a.text_md, 655 a.leading_snug, 656 t.atoms.text_contrast_medium, 657 a.text_center, 658 ]}> 659 <Trans>No contacts with the name {query} found</Trans> 660 </Text> 661 </View> 662 ) 663} 664 665function TotallyEmptyState() { 666 const t = useTheme() 667 668 return ( 669 <View 670 style={[ 671 a.flex_1, 672 a.flex_col, 673 a.align_center, 674 a.justify_center, 675 a.gap_lg, 676 {paddingTop: 140}, 677 a.px_5xl, 678 ]}> 679 <PersonXIcon width={64} style={[t.atoms.text_contrast_low]} /> 680 <Text style={[a.text_xl, a.font_bold, a.leading_snug, a.text_center]}> 681 <Trans>No contacts found</Trans> 682 </Text> 683 </View> 684 ) 685}