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