Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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