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