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