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