this repo has no description
0
fork

Configure Feed

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

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