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