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