Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
134
fork

Configure Feed

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

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