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