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