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