Bluesky app fork with some witchin' additions 💫
at 2ad84d7dfcefdb2a05b32fae284b2ade042b01b9 737 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 {useModerationOpts} from '#/state/preferences/moderation-opts' 14import {useActorSearch} from '#/state/queries/actor-search' 15import {usePreferencesQuery} from '#/state/queries/preferences' 16import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery' 17import {useSession} from '#/state/session' 18import {type Follow10ProgressGuide} from '#/state/shell/progress-guide' 19import {type ListMethods} from '#/view/com/util/List' 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 {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' 32import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 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 {useAnalytics} from '#/analytics' 38import {IS_WEB} from '#/env' 39import type * as bsky from '#/types/bsky' 40import {ProgressGuideTask} from './Task' 41 42type Item = 43 | { 44 type: 'profile' 45 key: string 46 profile: bsky.profile.AnyProfileView 47 } 48 | { 49 type: 'empty' 50 key: string 51 message: string 52 } 53 | { 54 type: 'placeholder' 55 key: string 56 } 57 | { 58 type: 'error' 59 key: string 60 } 61 62export function FollowDialog({ 63 guide, 64 showArrow, 65}: { 66 guide: Follow10ProgressGuide 67 showArrow?: boolean 68}) { 69 const ax = useAnalytics() 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 ax.metric('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 ax = useAnalytics() 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 ax.metric('suggestedUser:seen', { 276 logContext: 'ProgressGuide', 277 recId: undefined, 278 position: position !== -1 ? position : 0, 279 suggestedDid: item.profile.did, 280 category: selectedInterestRef.current, 281 }) 282 } 283 } 284 } 285 }, 286 ).current 287 const viewabilityConfig = useRef({ 288 itemVisiblePercentThreshold: 50, 289 }).current 290 291 const onSelectTab = useCallback( 292 (interest: string) => { 293 setSelectedInterest(interest) 294 inputRef.current?.clear() 295 setSearchText('') 296 listRef.current?.scrollToOffset({ 297 offset: 0, 298 animated: false, 299 }) 300 }, 301 [setSelectedInterest, setSearchText], 302 ) 303 304 const listHeader = ( 305 <Header 306 guide={guide} 307 inputRef={inputRef} 308 listRef={listRef} 309 searchText={searchText} 310 onSelectTab={onSelectTab} 311 setHeaderHeight={setHeaderHeight} 312 setSearchText={setSearchText} 313 interests={interests} 314 selectedInterest={selectedInterest} 315 interestsDisplayNames={interestsDisplayNames} 316 /> 317 ) 318 319 return ( 320 <Dialog.InnerFlatList 321 ref={listRef} 322 data={items} 323 renderItem={renderItems} 324 ListHeaderComponent={listHeader} 325 stickyHeaderIndices={[0]} 326 keyExtractor={(item: Item) => item.key} 327 style={[ 328 a.px_0, 329 web([a.py_0, {height: '100vh', maxHeight: 600}]), 330 native({height: '100%'}), 331 ]} 332 webInnerContentContainerStyle={a.py_0} 333 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 334 keyboardDismissMode="on-drag" 335 scrollIndicatorInsets={{top: headerHeight}} 336 initialNumToRender={8} 337 maxToRenderPerBatch={8} 338 onViewableItemsChanged={onViewableItemsChanged} 339 viewabilityConfig={viewabilityConfig} 340 /> 341 ) 342} 343 344let Header = ({ 345 guide, 346 inputRef, 347 listRef, 348 searchText, 349 onSelectTab, 350 setHeaderHeight, 351 setSearchText, 352 interests, 353 selectedInterest, 354 interestsDisplayNames, 355}: { 356 guide?: Follow10ProgressGuide 357 inputRef: React.RefObject<TextInput | null> 358 listRef: React.RefObject<ListMethods | null> 359 onSelectTab: (v: string) => void 360 searchText: string 361 setHeaderHeight: (v: number) => void 362 setSearchText: (v: string) => void 363 interests: string[] 364 selectedInterest: string 365 interestsDisplayNames: Record<string, string> 366}): React.ReactNode => { 367 const t = useTheme() 368 const control = Dialog.useDialogContext() 369 return ( 370 <View 371 onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} 372 style={[ 373 a.relative, 374 web(a.pt_lg), 375 native(a.pt_4xl), 376 a.pb_xs, 377 a.border_b, 378 t.atoms.border_contrast_low, 379 t.atoms.bg, 380 ]}> 381 <HeaderTop guide={guide} /> 382 383 <View style={[web(a.pt_xs), a.pb_xs]}> 384 <SearchInput 385 inputRef={inputRef} 386 defaultValue={searchText} 387 onChangeText={text => { 388 setSearchText(text) 389 listRef.current?.scrollToOffset({offset: 0, animated: false}) 390 }} 391 onEscape={control.close} 392 /> 393 <InterestTabs 394 onSelectTab={onSelectTab} 395 interests={interests} 396 selectedInterest={selectedInterest} 397 disabled={!!searchText} 398 interestsDisplayNames={interestsDisplayNames} 399 TabComponent={Tab} 400 /> 401 </View> 402 </View> 403 ) 404} 405Header = memo(Header) 406 407function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) { 408 const {_} = useLingui() 409 const t = useTheme() 410 const control = Dialog.useDialogContext() 411 return ( 412 <View 413 style={[ 414 a.px_lg, 415 a.relative, 416 a.flex_row, 417 a.justify_between, 418 a.align_center, 419 ]}> 420 <Text 421 style={[ 422 a.z_10, 423 a.text_lg, 424 a.font_bold, 425 a.leading_tight, 426 t.atoms.text_contrast_high, 427 ]}> 428 <Trans>Find people to follow</Trans> 429 </Text> 430 {guide && ( 431 <View style={IS_WEB && {paddingRight: 36}}> 432 <ProgressGuideTask 433 current={guide.numFollows + 1} 434 total={10 + 1} 435 title={`${guide.numFollows} / 10`} 436 tabularNumsTitle 437 /> 438 </View> 439 )} 440 {IS_WEB ? ( 441 <Button 442 label={_(msg`Close`)} 443 size="small" 444 shape="round" 445 variant={IS_WEB ? 'ghost' : 'solid'} 446 color="secondary" 447 style={[ 448 a.absolute, 449 a.z_20, 450 web({right: 8}), 451 native({right: 0}), 452 native({height: 32, width: 32, borderRadius: 16}), 453 ]} 454 onPress={() => control.close()}> 455 <ButtonIcon icon={X} size="md" /> 456 </Button> 457 ) : null} 458 </View> 459 ) 460} 461 462let Tab = ({ 463 onSelectTab, 464 interest, 465 active, 466 index, 467 interestsDisplayName, 468 onLayout, 469}: { 470 onSelectTab: (index: number) => void 471 interest: string 472 active: boolean 473 index: number 474 interestsDisplayName: string 475 onLayout: (index: number, x: number, width: number) => void 476}): React.ReactNode => { 477 const t = useTheme() 478 const {_} = useLingui() 479 const label = active 480 ? _( 481 msg({ 482 message: `Search for "${interestsDisplayName}" (active)`, 483 comment: 484 '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.', 485 }), 486 ) 487 : _( 488 msg({ 489 message: `Search for "${interestsDisplayName}"`, 490 comment: 491 '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.', 492 }), 493 ) 494 return ( 495 <View 496 key={interest} 497 onLayout={e => 498 onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 499 }> 500 <Button label={label} onPress={() => onSelectTab(index)}> 501 {({hovered, pressed}) => ( 502 <View 503 style={[ 504 a.rounded_full, 505 a.px_lg, 506 a.py_sm, 507 a.border, 508 active || hovered || pressed 509 ? [ 510 t.atoms.bg_contrast_25, 511 {borderColor: t.atoms.bg_contrast_25.backgroundColor}, 512 ] 513 : [t.atoms.bg, t.atoms.border_contrast_low], 514 ]}> 515 <Text 516 style={[ 517 a.font_medium, 518 active || hovered || pressed 519 ? t.atoms.text 520 : t.atoms.text_contrast_medium, 521 ]}> 522 {interestsDisplayName} 523 </Text> 524 </View> 525 )} 526 </Button> 527 </View> 528 ) 529} 530Tab = memo(Tab) 531 532let FollowProfileCard = ({ 533 profile, 534 moderationOpts, 535 noBorder, 536}: { 537 profile: bsky.profile.AnyProfileView 538 moderationOpts: ModerationOpts 539 noBorder?: boolean 540}): React.ReactNode => { 541 return ( 542 <FollowProfileCardInner 543 profile={profile} 544 moderationOpts={moderationOpts} 545 noBorder={noBorder} 546 /> 547 ) 548} 549FollowProfileCard = memo(FollowProfileCard) 550 551function FollowProfileCardInner({ 552 profile, 553 moderationOpts, 554 onFollow, 555 noBorder, 556}: { 557 profile: bsky.profile.AnyProfileView 558 moderationOpts: ModerationOpts 559 onFollow?: () => void 560 noBorder?: boolean 561}) { 562 const control = Dialog.useDialogContext() 563 const t = useTheme() 564 return ( 565 <ProfileCard.Link 566 profile={profile} 567 style={[a.flex_1]} 568 onPress={() => control.close()}> 569 {({hovered, pressed}) => ( 570 <CardOuter 571 style={[ 572 a.flex_1, 573 noBorder && a.border_t_0, 574 (hovered || pressed) && t.atoms.bg_contrast_25, 575 ]}> 576 <ProfileCard.Outer> 577 <ProfileCard.Header> 578 <ProfileCard.Avatar 579 disabledPreview={!IS_WEB} 580 profile={profile} 581 moderationOpts={moderationOpts} 582 /> 583 <ProfileCard.NameAndHandle 584 profile={profile} 585 moderationOpts={moderationOpts} 586 /> 587 <ProfileCard.FollowButton 588 profile={profile} 589 moderationOpts={moderationOpts} 590 logContext="PostOnboardingFindFollows" 591 shape="round" 592 onPress={onFollow} 593 colorInverted 594 /> 595 </ProfileCard.Header> 596 <ProfileCard.Description profile={profile} numberOfLines={2} /> 597 </ProfileCard.Outer> 598 </CardOuter> 599 )} 600 </ProfileCard.Link> 601 ) 602} 603 604function CardOuter({ 605 children, 606 style, 607}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { 608 const t = useTheme() 609 return ( 610 <View 611 style={[ 612 a.w_full, 613 a.py_md, 614 a.px_lg, 615 a.border_t, 616 t.atoms.border_contrast_low, 617 style, 618 ]}> 619 {children} 620 </View> 621 ) 622} 623 624function SearchInput({ 625 onChangeText, 626 onEscape, 627 inputRef, 628 defaultValue, 629}: { 630 onChangeText: (text: string) => void 631 onEscape: () => void 632 inputRef: React.RefObject<TextInput | null> 633 defaultValue: string 634}) { 635 const t = useTheme() 636 const {_} = useLingui() 637 const { 638 state: hovered, 639 onIn: onMouseEnter, 640 onOut: onMouseLeave, 641 } = useInteractionState() 642 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 643 const interacted = hovered || focused 644 645 return ( 646 <View 647 {...web({ 648 onMouseEnter, 649 onMouseLeave, 650 })} 651 style={[a.flex_row, a.align_center, a.gap_sm, a.px_lg, a.py_xs]}> 652 <SearchIcon 653 size="md" 654 fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 655 /> 656 657 <TextInput 658 ref={inputRef} 659 placeholder={_(msg`Search by name or interest`)} 660 defaultValue={defaultValue} 661 onChangeText={onChangeText} 662 onFocus={onFocus} 663 onBlur={onBlur} 664 style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 665 placeholderTextColor={t.palette.contrast_500} 666 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 667 returnKeyType="search" 668 clearButtonMode="while-editing" 669 maxLength={50} 670 onKeyPress={({nativeEvent}) => { 671 if (nativeEvent.key === 'Escape') { 672 onEscape() 673 } 674 }} 675 autoCorrect={false} 676 autoComplete="off" 677 autoCapitalize="none" 678 accessibilityLabel={_(msg`Search profiles`)} 679 accessibilityHint={_(msg`Searches for profiles`)} 680 /> 681 </View> 682 ) 683} 684 685function ProfileCardSkeleton() { 686 const t = useTheme() 687 688 return ( 689 <View 690 style={[ 691 a.flex_1, 692 a.py_md, 693 a.px_lg, 694 a.gap_md, 695 a.align_center, 696 a.flex_row, 697 ]}> 698 <View 699 style={[ 700 a.rounded_full, 701 {width: 42, height: 42}, 702 t.atoms.bg_contrast_25, 703 ]} 704 /> 705 706 <View style={[a.flex_1, a.gap_sm]}> 707 <View 708 style={[ 709 a.rounded_xs, 710 {width: 80, height: 14}, 711 t.atoms.bg_contrast_25, 712 ]} 713 /> 714 <View 715 style={[ 716 a.rounded_xs, 717 {width: 120, height: 10}, 718 t.atoms.bg_contrast_25, 719 ]} 720 /> 721 </View> 722 </View> 723 ) 724} 725 726function Empty({message}: {message: string}) { 727 const t = useTheme() 728 return ( 729 <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 730 <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 731 {message} 732 </Text> 733 734 <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(°°) </Text> 735 </View> 736 ) 737}