Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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