Bluesky app fork with some witchin' additions 💫
at main 744 lines 20 kB view raw
1import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2import { 3 TextInput, 4 useWindowDimensions, 5 View, 6 type ViewToken, 7} from 'react-native' 8import {type ModerationOpts} from '@atproto/api' 9import {msg, Trans} from '@lingui/macro' 10import {useLingui} from '@lingui/react' 11 12import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 13import {logEvent} from '#/lib/statsig/statsig' 14import {logger} from '#/logger' 15import {useModerationOpts} from '#/state/preferences/moderation-opts' 16import {useActorSearch} from '#/state/queries/actor-search' 17import {usePreferencesQuery} from '#/state/queries/preferences' 18import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery' 19import {useSession} from '#/state/session' 20import {type Follow10ProgressGuide} from '#/state/shell/progress-guide' 21import {type ListMethods} from '#/view/com/util/List' 22import { 23 atoms as a, 24 native, 25 useBreakpoints, 26 useTheme, 27 utils, 28 type ViewStyleProp, 29 web, 30} from '#/alf' 31import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32import * as Dialog from '#/components/Dialog' 33import {useInteractionState} from '#/components/hooks/useInteractionState' 34import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' 35import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 36import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 37import {boostInterests, InterestTabs} from '#/components/InterestTabs' 38import * as ProfileCard from '#/components/ProfileCard' 39import {Text} from '#/components/Typography' 40import {IS_WEB} from '#/env' 41import type * as bsky from '#/types/bsky' 42import {ProgressGuideTask} from './Task' 43 44type Item = 45 | { 46 type: 'profile' 47 key: string 48 profile: bsky.profile.AnyProfileView 49 } 50 | { 51 type: 'empty' 52 key: string 53 message: string 54 } 55 | { 56 type: 'placeholder' 57 key: string 58 } 59 | { 60 type: 'error' 61 key: string 62 } 63 64export function FollowDialog({ 65 guide, 66 showArrow, 67}: { 68 guide: Follow10ProgressGuide 69 showArrow?: boolean 70}) { 71 const {_} = useLingui() 72 const control = Dialog.useDialogControl() 73 const {gtPhone} = useBreakpoints() 74 const {height: minHeight} = useWindowDimensions() 75 76 return ( 77 <> 78 <Button 79 label={_(msg`Find people to follow`)} 80 onPress={() => { 81 control.open() 82 logEvent('progressGuide:followDialog:open', {}) 83 }} 84 size={gtPhone ? 'small' : 'large'} 85 color="primary"> 86 <ButtonText> 87 <Trans>Find people to follow</Trans> 88 </ButtonText> 89 {showArrow && <ButtonIcon icon={ArrowRightIcon} />} 90 </Button> 91 <Dialog.Outer control={control} nativeOptions={{minHeight}}> 92 <Dialog.Handle /> 93 <DialogInner guide={guide} /> 94 </Dialog.Outer> 95 </> 96 ) 97} 98 99/** 100 * Same as {@link FollowDialog} but without a progress guide. 101 */ 102export function FollowDialogWithoutGuide({ 103 control, 104}: { 105 control: Dialog.DialogOuterProps['control'] 106}) { 107 const {height: minHeight} = useWindowDimensions() 108 return ( 109 <Dialog.Outer control={control} nativeOptions={{minHeight}}> 110 <Dialog.Handle /> 111 <DialogInner /> 112 </Dialog.Outer> 113 ) 114} 115 116// Fine to keep this top-level. 117let lastSelectedInterest = '' 118let lastSearchText = '' 119 120function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { 121 const {_} = useLingui() 122 const interestsDisplayNames = useInterestsDisplayNames() 123 const {data: preferences} = usePreferencesQuery() 124 const personalizedInterests = preferences?.interests?.tags 125 const interests = Object.keys(interestsDisplayNames) 126 .sort(boostInterests(popularInterests)) 127 .sort(boostInterests(personalizedInterests)) 128 const [selectedInterest, setSelectedInterest] = useState( 129 () => 130 lastSelectedInterest || 131 (personalizedInterests && interests.includes(personalizedInterests[0]) 132 ? personalizedInterests[0] 133 : interests[0]), 134 ) 135 const [searchText, setSearchText] = useState(lastSearchText) 136 const moderationOpts = useModerationOpts() 137 const listRef = useRef<ListMethods>(null) 138 const inputRef = useRef<TextInput>(null) 139 const [headerHeight, setHeaderHeight] = useState(0) 140 const {currentAccount} = useSession() 141 142 useEffect(() => { 143 lastSearchText = searchText 144 lastSelectedInterest = selectedInterest 145 }, [searchText, selectedInterest]) 146 147 const { 148 data: suggestions, 149 isFetching: isFetchingSuggestions, 150 error: suggestionsError, 151 } = useGetSuggestedUsersQuery({ 152 category: selectedInterest, 153 limit: 50, 154 }) 155 const { 156 data: searchResults, 157 isFetching: isFetchingSearchResults, 158 error: searchResultsError, 159 isError: isSearchResultsError, 160 } = useActorSearch({ 161 enabled: !!searchText, 162 query: searchText, 163 }) 164 165 const hasSearchText = !!searchText 166 const resultsKey = searchText || selectedInterest 167 const items = useMemo(() => { 168 const results = hasSearchText 169 ? searchResults?.pages.flatMap(p => p.actors) 170 : suggestions?.actors 171 let _items: Item[] = [] 172 173 if (isFetchingSuggestions || isFetchingSearchResults) { 174 const placeholders: Item[] = Array(10) 175 .fill(0) 176 .map((__, i) => ({ 177 type: 'placeholder', 178 key: i + '', 179 })) 180 181 _items.push(...placeholders) 182 } else if ( 183 (hasSearchText && searchResultsError) || 184 (!hasSearchText && suggestionsError) || 185 !results?.length 186 ) { 187 _items.push({ 188 type: 'empty', 189 key: 'empty', 190 message: _(msg`We're having network issues, try again`), 191 }) 192 } else { 193 const seen = new Set<string>() 194 for (const profile of results) { 195 if (seen.has(profile.did)) continue 196 if (profile.did === currentAccount?.did) continue 197 if (profile.viewer?.following) continue 198 199 seen.add(profile.did) 200 201 _items.push({ 202 type: 'profile', 203 // Don't share identity across tabs or typing attempts 204 key: resultsKey + ':' + profile.did, 205 profile, 206 }) 207 } 208 } 209 210 return _items 211 }, [ 212 _, 213 suggestions, 214 suggestionsError, 215 isFetchingSuggestions, 216 searchResults, 217 searchResultsError, 218 isFetchingSearchResults, 219 currentAccount?.did, 220 hasSearchText, 221 resultsKey, 222 ]) 223 224 if ( 225 searchText && 226 !isFetchingSearchResults && 227 !items.length && 228 !isSearchResultsError 229 ) { 230 items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) 231 } 232 233 const renderItems = useCallback( 234 ({item, index}: {item: Item; index: number}) => { 235 switch (item.type) { 236 case 'profile': { 237 return ( 238 <FollowProfileCard 239 profile={item.profile} 240 moderationOpts={moderationOpts!} 241 noBorder={index === 0} 242 /> 243 ) 244 } 245 case 'placeholder': { 246 return <ProfileCardSkeleton key={item.key} /> 247 } 248 case 'empty': { 249 return <Empty key={item.key} message={item.message} /> 250 } 251 default: 252 return null 253 } 254 }, 255 [moderationOpts], 256 ) 257 258 // Track seen profiles 259 const seenProfilesRef = useRef<Set<string>>(new Set()) 260 const itemsRef = useRef(items) 261 itemsRef.current = items 262 const selectedInterestRef = useRef(selectedInterest) 263 selectedInterestRef.current = selectedInterest 264 265 const onViewableItemsChanged = useRef( 266 ({viewableItems}: {viewableItems: ViewToken[]}) => { 267 for (const viewableItem of viewableItems) { 268 const item = viewableItem.item as Item 269 if (item.type === 'profile') { 270 if (!seenProfilesRef.current.has(item.profile.did)) { 271 seenProfilesRef.current.add(item.profile.did) 272 const position = itemsRef.current.findIndex( 273 i => i.type === 'profile' && i.profile.did === item.profile.did, 274 ) 275 logger.metric( 276 'suggestedUser:seen', 277 { 278 logContext: 'ProgressGuide', 279 recId: undefined, 280 position: position !== -1 ? position : 0, 281 suggestedDid: item.profile.did, 282 category: selectedInterestRef.current, 283 }, 284 {statsig: true}, 285 ) 286 } 287 } 288 } 289 }, 290 ).current 291 const viewabilityConfig = useRef({ 292 itemVisiblePercentThreshold: 50, 293 }).current 294 295 const onSelectTab = useCallback( 296 (interest: string) => { 297 setSelectedInterest(interest) 298 inputRef.current?.clear() 299 setSearchText('') 300 listRef.current?.scrollToOffset({ 301 offset: 0, 302 animated: false, 303 }) 304 }, 305 [setSelectedInterest, setSearchText], 306 ) 307 308 const listHeader = ( 309 <Header 310 guide={guide} 311 inputRef={inputRef} 312 listRef={listRef} 313 searchText={searchText} 314 onSelectTab={onSelectTab} 315 setHeaderHeight={setHeaderHeight} 316 setSearchText={setSearchText} 317 interests={interests} 318 selectedInterest={selectedInterest} 319 interestsDisplayNames={interestsDisplayNames} 320 /> 321 ) 322 323 return ( 324 <Dialog.InnerFlatList 325 ref={listRef} 326 data={items} 327 renderItem={renderItems} 328 ListHeaderComponent={listHeader} 329 stickyHeaderIndices={[0]} 330 keyExtractor={(item: Item) => item.key} 331 style={[ 332 a.px_0, 333 web([a.py_0, {height: '100vh', maxHeight: 600}]), 334 native({height: '100%'}), 335 ]} 336 webInnerContentContainerStyle={a.py_0} 337 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 338 keyboardDismissMode="on-drag" 339 scrollIndicatorInsets={{top: headerHeight}} 340 initialNumToRender={8} 341 maxToRenderPerBatch={8} 342 onViewableItemsChanged={onViewableItemsChanged} 343 viewabilityConfig={viewabilityConfig} 344 /> 345 ) 346} 347 348let Header = ({ 349 guide, 350 inputRef, 351 listRef, 352 searchText, 353 onSelectTab, 354 setHeaderHeight, 355 setSearchText, 356 interests, 357 selectedInterest, 358 interestsDisplayNames, 359}: { 360 guide?: Follow10ProgressGuide 361 inputRef: React.RefObject<TextInput | null> 362 listRef: React.RefObject<ListMethods | null> 363 onSelectTab: (v: string) => void 364 searchText: string 365 setHeaderHeight: (v: number) => void 366 setSearchText: (v: string) => void 367 interests: string[] 368 selectedInterest: string 369 interestsDisplayNames: Record<string, string> 370}): React.ReactNode => { 371 const t = useTheme() 372 const control = Dialog.useDialogContext() 373 return ( 374 <View 375 onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} 376 style={[ 377 a.relative, 378 web(a.pt_lg), 379 native(a.pt_4xl), 380 a.pb_xs, 381 a.border_b, 382 t.atoms.border_contrast_low, 383 t.atoms.bg, 384 ]}> 385 <HeaderTop guide={guide} /> 386 387 <View style={[web(a.pt_xs), a.pb_xs]}> 388 <SearchInput 389 inputRef={inputRef} 390 defaultValue={searchText} 391 onChangeText={text => { 392 setSearchText(text) 393 listRef.current?.scrollToOffset({offset: 0, animated: false}) 394 }} 395 onEscape={control.close} 396 /> 397 <InterestTabs 398 onSelectTab={onSelectTab} 399 interests={interests} 400 selectedInterest={selectedInterest} 401 disabled={!!searchText} 402 interestsDisplayNames={interestsDisplayNames} 403 TabComponent={Tab} 404 /> 405 </View> 406 </View> 407 ) 408} 409Header = memo(Header) 410 411function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) { 412 const {_} = useLingui() 413 const t = useTheme() 414 const control = Dialog.useDialogContext() 415 return ( 416 <View 417 style={[ 418 a.px_lg, 419 a.relative, 420 a.flex_row, 421 a.justify_between, 422 a.align_center, 423 ]}> 424 <Text 425 style={[ 426 a.z_10, 427 a.text_lg, 428 a.font_bold, 429 a.leading_tight, 430 t.atoms.text_contrast_high, 431 ]}> 432 <Trans>Find people to follow</Trans> 433 </Text> 434 {guide && ( 435 <View style={IS_WEB && {paddingRight: 36}}> 436 <ProgressGuideTask 437 current={guide.numFollows + 1} 438 total={10 + 1} 439 title={`${guide.numFollows} / 10`} 440 tabularNumsTitle 441 /> 442 </View> 443 )} 444 {IS_WEB ? ( 445 <Button 446 label={_(msg`Close`)} 447 size="small" 448 shape="round" 449 variant={IS_WEB ? 'ghost' : 'solid'} 450 color="secondary" 451 style={[ 452 a.absolute, 453 a.z_20, 454 web({right: 8}), 455 native({right: 0}), 456 native({height: 32, width: 32, borderRadius: 16}), 457 ]} 458 onPress={() => control.close()}> 459 <ButtonIcon icon={X} size="md" /> 460 </Button> 461 ) : null} 462 </View> 463 ) 464} 465 466let Tab = ({ 467 onSelectTab, 468 interest, 469 active, 470 index, 471 interestsDisplayName, 472 onLayout, 473}: { 474 onSelectTab: (index: number) => void 475 interest: string 476 active: boolean 477 index: number 478 interestsDisplayName: string 479 onLayout: (index: number, x: number, width: number) => void 480}): React.ReactNode => { 481 const t = useTheme() 482 const {_} = useLingui() 483 const label = active 484 ? _( 485 msg({ 486 message: `Search for "${interestsDisplayName}" (active)`, 487 comment: 488 'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected.', 489 }), 490 ) 491 : _( 492 msg({ 493 message: `Search for "${interestsDisplayName}"`, 494 comment: 495 'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected.', 496 }), 497 ) 498 return ( 499 <View 500 key={interest} 501 onLayout={e => 502 onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 503 }> 504 <Button label={label} onPress={() => onSelectTab(index)}> 505 {({hovered, pressed}) => ( 506 <View 507 style={[ 508 a.rounded_full, 509 a.px_lg, 510 a.py_sm, 511 a.border, 512 active || hovered || pressed 513 ? [ 514 t.atoms.bg_contrast_25, 515 {borderColor: t.atoms.bg_contrast_25.backgroundColor}, 516 ] 517 : [t.atoms.bg, t.atoms.border_contrast_low], 518 ]}> 519 <Text 520 style={[ 521 a.font_medium, 522 active || hovered || pressed 523 ? t.atoms.text 524 : t.atoms.text_contrast_medium, 525 ]}> 526 {interestsDisplayName} 527 </Text> 528 </View> 529 )} 530 </Button> 531 </View> 532 ) 533} 534Tab = memo(Tab) 535 536let FollowProfileCard = ({ 537 profile, 538 moderationOpts, 539 noBorder, 540}: { 541 profile: bsky.profile.AnyProfileView 542 moderationOpts: ModerationOpts 543 noBorder?: boolean 544}): React.ReactNode => { 545 return ( 546 <FollowProfileCardInner 547 profile={profile} 548 moderationOpts={moderationOpts} 549 noBorder={noBorder} 550 /> 551 ) 552} 553FollowProfileCard = memo(FollowProfileCard) 554 555function FollowProfileCardInner({ 556 profile, 557 moderationOpts, 558 onFollow, 559 noBorder, 560}: { 561 profile: bsky.profile.AnyProfileView 562 moderationOpts: ModerationOpts 563 onFollow?: () => void 564 noBorder?: boolean 565}) { 566 const control = Dialog.useDialogContext() 567 const t = useTheme() 568 return ( 569 <ProfileCard.Link 570 profile={profile} 571 style={[a.flex_1]} 572 onPress={() => control.close()}> 573 {({hovered, pressed}) => ( 574 <CardOuter 575 style={[ 576 a.flex_1, 577 noBorder && a.border_t_0, 578 (hovered || pressed) && t.atoms.bg_contrast_25, 579 ]}> 580 <ProfileCard.Outer> 581 <ProfileCard.Header> 582 <ProfileCard.Avatar 583 disabledPreview={!IS_WEB} 584 profile={profile} 585 moderationOpts={moderationOpts} 586 /> 587 <ProfileCard.NameAndHandle 588 profile={profile} 589 moderationOpts={moderationOpts} 590 /> 591 <ProfileCard.FollowButton 592 profile={profile} 593 moderationOpts={moderationOpts} 594 logContext="PostOnboardingFindFollows" 595 shape="round" 596 onPress={onFollow} 597 colorInverted 598 /> 599 </ProfileCard.Header> 600 <ProfileCard.Description profile={profile} numberOfLines={2} /> 601 </ProfileCard.Outer> 602 </CardOuter> 603 )} 604 </ProfileCard.Link> 605 ) 606} 607 608function CardOuter({ 609 children, 610 style, 611}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { 612 const t = useTheme() 613 return ( 614 <View 615 style={[ 616 a.w_full, 617 a.py_md, 618 a.px_lg, 619 a.border_t, 620 t.atoms.border_contrast_low, 621 style, 622 ]}> 623 {children} 624 </View> 625 ) 626} 627 628function SearchInput({ 629 onChangeText, 630 onEscape, 631 inputRef, 632 defaultValue, 633}: { 634 onChangeText: (text: string) => void 635 onEscape: () => void 636 inputRef: React.RefObject<TextInput | null> 637 defaultValue: string 638}) { 639 const t = useTheme() 640 const {_} = useLingui() 641 const { 642 state: hovered, 643 onIn: onMouseEnter, 644 onOut: onMouseLeave, 645 } = useInteractionState() 646 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 647 const interacted = hovered || focused 648 649 return ( 650 <View 651 {...web({ 652 onMouseEnter, 653 onMouseLeave, 654 })} 655 style={[a.flex_row, a.align_center, a.gap_sm, a.px_lg, a.py_xs]}> 656 <SearchIcon 657 size="md" 658 fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 659 /> 660 661 <TextInput 662 ref={inputRef} 663 placeholder={_(msg`Search by name or interest`)} 664 defaultValue={defaultValue} 665 onChangeText={onChangeText} 666 onFocus={onFocus} 667 onBlur={onBlur} 668 selectionColor={utils.alpha(t.palette.primary_500, 0.4)} 669 cursorColor={t.palette.primary_500} 670 selectionHandleColor={t.palette.primary_500} 671 style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 672 placeholderTextColor={t.palette.contrast_500} 673 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 674 returnKeyType="search" 675 clearButtonMode="while-editing" 676 maxLength={50} 677 onKeyPress={({nativeEvent}) => { 678 if (nativeEvent.key === 'Escape') { 679 onEscape() 680 } 681 }} 682 autoCorrect={false} 683 autoComplete="off" 684 autoCapitalize="none" 685 accessibilityLabel={_(msg`Search profiles`)} 686 accessibilityHint={_(msg`Searches for profiles`)} 687 /> 688 </View> 689 ) 690} 691 692function ProfileCardSkeleton() { 693 const t = useTheme() 694 695 return ( 696 <View 697 style={[ 698 a.flex_1, 699 a.py_md, 700 a.px_lg, 701 a.gap_md, 702 a.align_center, 703 a.flex_row, 704 ]}> 705 <View 706 style={[ 707 a.rounded_full, 708 {width: 42, height: 42}, 709 t.atoms.bg_contrast_25, 710 ]} 711 /> 712 713 <View style={[a.flex_1, a.gap_sm]}> 714 <View 715 style={[ 716 a.rounded_xs, 717 {width: 80, height: 14}, 718 t.atoms.bg_contrast_25, 719 ]} 720 /> 721 <View 722 style={[ 723 a.rounded_xs, 724 {width: 120, height: 10}, 725 t.atoms.bg_contrast_25, 726 ]} 727 /> 728 </View> 729 </View> 730 ) 731} 732 733function Empty({message}: {message: string}) { 734 const t = useTheme() 735 return ( 736 <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 737 <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 738 {message} 739 </Text> 740 741 <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(°°) </Text> 742 </View> 743 ) 744}