Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at bwc9876/pdsls-in-profile 767 lines 21 kB view raw
1import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2import {TextInput, View, type ViewToken} from 'react-native' 3import {type ModerationOpts} from '@atproto/api' 4import {Trans, useLingui} from '@lingui/react/macro' 5 6import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 7import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 8import {useModerationOpts} from '#/state/preferences/moderation-opts' 9import {useActorSearch} from '#/state/queries/actor-search' 10import {usePreferencesQuery} from '#/state/queries/preferences' 11import {useGetSuggestedUsersForSeeMoreQuery} from '#/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery' 12import {useSession} from '#/state/session' 13import {type Follow10ProgressGuide} from '#/state/shell/progress-guide' 14import {type ListMethods} from '#/view/com/util/List' 15import { 16 atoms as a, 17 native, 18 useBreakpoints, 19 useTheme, 20 utils, 21 type ViewStyleProp, 22 web, 23} from '#/alf' 24import {Button, ButtonIcon, ButtonText} from '#/components/Button' 25import * as Dialog from '#/components/Dialog' 26import {useInteractionState} from '#/components/hooks/useInteractionState' 27import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' 28import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 29import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 30import {boostInterests, InterestTabs} from '#/components/InterestTabs' 31import * as ProfileCard from '#/components/ProfileCard' 32import {Text} from '#/components/Typography' 33import {useAnalytics} from '#/analytics' 34import {IS_WEB} from '#/env' 35import type * as bsky from '#/types/bsky' 36import {ProgressGuideTask} from './Task' 37 38type Item = 39 | { 40 type: 'profile' 41 key: string 42 profile: bsky.profile.AnyProfileView 43 } 44 | { 45 type: 'empty' 46 key: string 47 message: string 48 } 49 | { 50 type: 'placeholder' 51 key: string 52 } 53 | { 54 type: 'error' 55 key: string 56 } 57 58export function FollowDialog({ 59 guide, 60 showArrow, 61}: { 62 guide: Follow10ProgressGuide 63 showArrow?: boolean 64}) { 65 const ax = useAnalytics() 66 const {t: l} = useLingui() 67 const control = Dialog.useDialogControl() 68 const {gtPhone} = useBreakpoints() 69 70 return ( 71 <> 72 <Button 73 label={l`Find people to follow`} 74 onPress={() => { 75 control.open() 76 ax.metric('progressGuide:followDialog:open', {}) 77 }} 78 size={gtPhone ? 'small' : 'large'} 79 color="primary"> 80 <ButtonText> 81 <Trans>Find people to follow</Trans> 82 </ButtonText> 83 {showArrow && <ButtonIcon icon={ArrowRightIcon} />} 84 </Button> 85 <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 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 return ( 102 <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 103 <Dialog.Handle /> 104 <DialogInner /> 105 </Dialog.Outer> 106 ) 107} 108 109// Fine to keep this top-level. 110let lastSelectedInterest = '' 111let lastSearchText = '' 112 113function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { 114 const {t: l} = useLingui() 115 const ax = useAnalytics() 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 } = useGetSuggestedUsersForSeeMoreQuery({ 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: l`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 if ( 205 hasSearchText && 206 !isFetchingSearchResults && 207 !_items.length && 208 !isSearchResultsError 209 ) { 210 _items.push({type: 'empty', key: 'empty', message: l`No results`}) 211 } 212 213 return _items 214 }, [ 215 l, 216 suggestions, 217 suggestionsError, 218 isFetchingSuggestions, 219 searchResults, 220 searchResultsError, 221 isFetchingSearchResults, 222 currentAccount?.did, 223 hasSearchText, 224 resultsKey, 225 isSearchResultsError, 226 ]) 227 228 const isGuide = Boolean(guide) 229 const recIdForLogging = hasSearchText ? undefined : suggestions?.recId 230 231 const renderItems = useCallback( 232 ({item, index}: {item: Item; index: number}) => { 233 switch (item.type) { 234 case 'profile': { 235 return ( 236 <FollowProfileCard 237 profile={item.profile} 238 moderationOpts={moderationOpts!} 239 noBorder={index === 0} 240 position={index} 241 recId={recIdForLogging} 242 isGuide={isGuide} 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, recIdForLogging, isGuide], 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 = useNonReactiveCallback( 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: isGuide ? 'ProgressGuide' : 'SeeMoreSuggestedUsers', 278 recId: recIdForLogging, 279 position: position !== -1 ? position : 0, 280 suggestedDid: item.profile.did, 281 category: selectedInterestRef.current, 282 }) 283 } 284 } 285 } 286 }, 287 ) 288 const viewabilityConfig = useMemo( 289 () => ({ 290 itemVisiblePercentThreshold: 50, 291 }), 292 [], 293 ) 294 295 const onSelectTab = useCallback( 296 (interest: string) => { 297 setSelectedInterest(interest) 298 inputRef.current?.clear() 299 setSearchText('') 300 listRef.current?.scrollToOffset({ 301 offset: 0, 302 animated: false, 303 }) 304 }, 305 [setSelectedInterest, setSearchText], 306 ) 307 308 const listHeader = ( 309 <Header 310 guide={guide} 311 inputRef={inputRef} 312 listRef={listRef} 313 searchText={searchText} 314 onSelectTab={onSelectTab} 315 setHeaderHeight={setHeaderHeight} 316 setSearchText={setSearchText} 317 interests={interests} 318 selectedInterest={selectedInterest} 319 interestsDisplayNames={interestsDisplayNames} 320 /> 321 ) 322 323 return ( 324 <Dialog.InnerFlatList 325 ref={listRef} 326 data={items} 327 renderItem={renderItems} 328 ListHeaderComponent={listHeader} 329 stickyHeaderIndices={[0]} 330 keyExtractor={(item: Item) => item.key} 331 style={[ 332 a.px_0, 333 web([a.py_0, {height: '100vh', maxHeight: 600}]), 334 native({height: '100%'}), 335 ]} 336 webInnerContentContainerStyle={a.py_0} 337 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 338 keyboardDismissMode="on-drag" 339 scrollIndicatorInsets={{top: headerHeight}} 340 initialNumToRender={8} 341 maxToRenderPerBatch={8} 342 onViewableItemsChanged={onViewableItemsChanged} 343 viewabilityConfig={viewabilityConfig} 344 /> 345 ) 346} 347 348let Header = ({ 349 guide, 350 inputRef, 351 listRef, 352 searchText, 353 onSelectTab, 354 setHeaderHeight, 355 setSearchText, 356 interests, 357 selectedInterest, 358 interestsDisplayNames, 359}: { 360 guide?: Follow10ProgressGuide 361 inputRef: React.RefObject<TextInput | null> 362 listRef: React.RefObject<ListMethods | null> 363 onSelectTab: (v: string) => void 364 searchText: string 365 setHeaderHeight: (v: number) => void 366 setSearchText: (v: string) => void 367 interests: string[] 368 selectedInterest: string 369 interestsDisplayNames: Record<string, string> 370}): React.ReactNode => { 371 const t = useTheme() 372 const control = Dialog.useDialogContext() 373 return ( 374 <View 375 onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} 376 style={[ 377 a.relative, 378 web(a.pt_lg), 379 native(a.pt_4xl), 380 a.pb_xs, 381 a.border_b, 382 t.atoms.border_contrast_low, 383 t.atoms.bg, 384 ]}> 385 <HeaderTop guide={guide} /> 386 387 <View style={[web(a.pt_xs), a.pb_xs]}> 388 <SearchInput 389 inputRef={inputRef} 390 defaultValue={searchText} 391 onChangeText={text => { 392 setSearchText(text) 393 listRef.current?.scrollToOffset({offset: 0, animated: false}) 394 }} 395 onEscape={control.close} 396 /> 397 <InterestTabs 398 onSelectTab={onSelectTab} 399 interests={interests} 400 selectedInterest={selectedInterest} 401 disabled={!!searchText} 402 interestsDisplayNames={interestsDisplayNames} 403 TabComponent={Tab} 404 /> 405 </View> 406 </View> 407 ) 408} 409Header = memo(Header) 410 411function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) { 412 const {t: l} = useLingui() 413 const t = useTheme() 414 const control = Dialog.useDialogContext() 415 return ( 416 <View 417 style={[ 418 a.px_lg, 419 a.relative, 420 a.flex_row, 421 a.justify_between, 422 a.align_center, 423 ]}> 424 <Text 425 style={[ 426 a.z_10, 427 a.text_lg, 428 a.font_bold, 429 a.leading_tight, 430 t.atoms.text_contrast_high, 431 ]}> 432 <Trans>Find people to follow</Trans> 433 </Text> 434 {guide && ( 435 <View style={IS_WEB && {paddingRight: 36}}> 436 <ProgressGuideTask 437 current={guide.numFollows + 1} 438 total={10 + 1} 439 title={`${guide.numFollows} / 10`} 440 tabularNumsTitle 441 /> 442 </View> 443 )} 444 {IS_WEB ? ( 445 <Button 446 label={l`Close`} 447 size="small" 448 shape="round" 449 variant={IS_WEB ? 'ghost' : 'solid'} 450 color="secondary" 451 style={[ 452 a.absolute, 453 a.z_20, 454 web({right: 8}), 455 native({right: 0}), 456 native({height: 32, width: 32, borderRadius: 16}), 457 ]} 458 onPress={() => control.close()}> 459 <ButtonIcon icon={X} size="md" /> 460 </Button> 461 ) : null} 462 </View> 463 ) 464} 465 466let Tab = ({ 467 onSelectTab, 468 interest, 469 active, 470 index, 471 interestsDisplayName, 472 onLayout, 473}: { 474 onSelectTab: (index: number) => void 475 interest: string 476 active: boolean 477 index: number 478 interestsDisplayName: string 479 onLayout: (index: number, x: number, width: number) => void 480}): React.ReactNode => { 481 const t = useTheme() 482 const {t: l} = useLingui() 483 const label = active 484 ? l({ 485 message: `Search for "${interestsDisplayName}" (active)`, 486 comment: 487 '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.', 488 }) 489 : l({ 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 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 position, 537 recId, 538 isGuide, 539}: { 540 profile: bsky.profile.AnyProfileView 541 moderationOpts: ModerationOpts 542 noBorder?: boolean 543 position: number 544 recId?: string 545 isGuide: boolean 546}): React.ReactNode => { 547 return ( 548 <FollowProfileCardInner 549 profile={profile} 550 moderationOpts={moderationOpts} 551 noBorder={noBorder} 552 position={position} 553 recId={recId} 554 isGuide={isGuide} 555 /> 556 ) 557} 558FollowProfileCard = memo(FollowProfileCard) 559 560function FollowProfileCardInner({ 561 profile, 562 moderationOpts, 563 onFollow, 564 noBorder, 565 position, 566 recId, 567 isGuide, 568}: { 569 profile: bsky.profile.AnyProfileView 570 moderationOpts: ModerationOpts 571 onFollow?: () => void 572 noBorder?: boolean 573 position: number 574 recId?: string 575 isGuide: boolean 576}) { 577 const control = Dialog.useDialogContext() 578 const t = useTheme() 579 const ax = useAnalytics() 580 return ( 581 <ProfileCard.Link 582 profile={profile} 583 style={[a.flex_1]} 584 onPress={() => control.close()}> 585 {({hovered, pressed}) => ( 586 <CardOuter 587 style={[ 588 a.flex_1, 589 noBorder && a.border_t_0, 590 (hovered || pressed) && t.atoms.bg_contrast_25, 591 ]}> 592 <ProfileCard.Outer> 593 <ProfileCard.Header> 594 <ProfileCard.Avatar 595 disabledPreview={!IS_WEB} 596 profile={profile} 597 moderationOpts={moderationOpts} 598 /> 599 <ProfileCard.NameAndHandle 600 profile={profile} 601 moderationOpts={moderationOpts} 602 /> 603 <ProfileCard.FollowButton 604 profile={profile} 605 moderationOpts={moderationOpts} 606 logContext="PostOnboardingFindFollows" 607 shape="round" 608 onPress={() => { 609 ax.metric('suggestedUser:follow', { 610 logContext: isGuide 611 ? 'ProgressGuide' 612 : 'SeeMoreSuggestedUsers', 613 location: 'Card', 614 recId, 615 position, 616 suggestedDid: profile.did, 617 category: null, 618 }) 619 onFollow?.() 620 }} 621 colorInverted 622 /> 623 </ProfileCard.Header> 624 <ProfileCard.Description profile={profile} numberOfLines={2} /> 625 </ProfileCard.Outer> 626 </CardOuter> 627 )} 628 </ProfileCard.Link> 629 ) 630} 631 632function CardOuter({ 633 children, 634 style, 635}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { 636 const t = useTheme() 637 return ( 638 <View 639 style={[ 640 a.w_full, 641 a.py_md, 642 a.px_lg, 643 a.border_t, 644 t.atoms.border_contrast_low, 645 style, 646 ]}> 647 {children} 648 </View> 649 ) 650} 651 652function SearchInput({ 653 onChangeText, 654 onEscape, 655 inputRef, 656 defaultValue, 657}: { 658 onChangeText: (text: string) => void 659 onEscape: () => void 660 inputRef: React.RefObject<TextInput | null> 661 defaultValue: string 662}) { 663 const t = useTheme() 664 const {t: l} = useLingui() 665 const { 666 state: hovered, 667 onIn: onMouseEnter, 668 onOut: onMouseLeave, 669 } = useInteractionState() 670 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 671 const interacted = hovered || focused 672 673 return ( 674 <View 675 {...web({ 676 onMouseEnter, 677 onMouseLeave, 678 })} 679 style={[a.flex_row, a.align_center, a.gap_sm, a.px_lg, a.py_xs]}> 680 <SearchIcon 681 size="md" 682 fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 683 /> 684 <TextInput 685 ref={inputRef} 686 placeholder={l`Search by name or interest`} 687 defaultValue={defaultValue} 688 onChangeText={onChangeText} 689 onFocus={onFocus} 690 onBlur={onBlur} 691 selectionColor={utils.alpha(t.palette.primary_500, 0.4)} 692 cursorColor={t.palette.primary_500} 693 selectionHandleColor={t.palette.primary_500} 694 style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 695 placeholderTextColor={t.palette.contrast_500} 696 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 697 returnKeyType="search" 698 clearButtonMode="while-editing" 699 maxLength={50} 700 onKeyPress={({nativeEvent}) => { 701 if (nativeEvent.key === 'Escape') { 702 onEscape() 703 } 704 }} 705 autoCorrect={false} 706 autoComplete="off" 707 autoCapitalize="none" 708 accessibilityLabel={l`Search profiles`} 709 accessibilityHint={l`Searches for profiles`} 710 /> 711 </View> 712 ) 713} 714 715function ProfileCardSkeleton() { 716 const t = useTheme() 717 718 return ( 719 <View 720 style={[ 721 a.flex_1, 722 a.py_md, 723 a.px_lg, 724 a.gap_md, 725 a.align_center, 726 a.flex_row, 727 ]}> 728 <View 729 style={[ 730 a.rounded_full, 731 {width: 42, height: 42}, 732 t.atoms.bg_contrast_25, 733 ]} 734 /> 735 736 <View style={[a.flex_1, a.gap_sm]}> 737 <View 738 style={[ 739 a.rounded_xs, 740 {width: 80, height: 14}, 741 t.atoms.bg_contrast_25, 742 ]} 743 /> 744 <View 745 style={[ 746 a.rounded_xs, 747 {width: 120, height: 10}, 748 t.atoms.bg_contrast_25, 749 ]} 750 /> 751 </View> 752 </View> 753 ) 754} 755 756function Empty({message}: {message: string}) { 757 const t = useTheme() 758 return ( 759 <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 760 <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 761 {message} 762 </Text> 763 764 <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(°°) </Text> 765 </View> 766 ) 767}