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