mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native' 3import Animated, { 4 LayoutAnimationConfig, 5 LinearTransition, 6 ZoomInEasyDown, 7} from 'react-native-reanimated' 8import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' 9import {msg, Trans} from '@lingui/macro' 10import {useLingui} from '@lingui/react' 11 12import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 13import {logEvent} from '#/lib/statsig/statsig' 14import {cleanError} from '#/lib/strings/errors' 15import {logger} from '#/logger' 16import {isWeb} from '#/platform/detection' 17import {useModerationOpts} from '#/state/preferences/moderation-opts' 18import {useActorSearchPaginated} from '#/state/queries/actor-search' 19import {usePreferencesQuery} from '#/state/queries/preferences' 20import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 21import {useSession} from '#/state/session' 22import {Follow10ProgressGuide} from '#/state/shell/progress-guide' 23import {ListMethods} from '#/view/com/util/List' 24import { 25 popularInterests, 26 useInterestsDisplayNames, 27} from '#/screens/Onboarding/state' 28import { 29 atoms as a, 30 native, 31 tokens, 32 useBreakpoints, 33 useTheme, 34 ViewStyleProp, 35 web, 36} from '#/alf' 37import {Button, ButtonIcon, ButtonText} from '#/components/Button' 38import * as Dialog from '#/components/Dialog' 39import {useInteractionState} from '#/components/hooks/useInteractionState' 40import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' 41import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 42import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 43import * as ProfileCard from '#/components/ProfileCard' 44import {Text} from '#/components/Typography' 45import {ListFooter} from '../Lists' 46import {ProgressGuideTask} from './Task' 47 48type Item = 49 | { 50 type: 'profile' 51 key: string 52 profile: AppBskyActorDefs.ProfileView 53 isSuggestion: boolean 54 } 55 | { 56 type: 'empty' 57 key: string 58 message: string 59 } 60 | { 61 type: 'placeholder' 62 key: string 63 } 64 | { 65 type: 'error' 66 key: string 67 } 68 69export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) { 70 const {_} = useLingui() 71 const control = Dialog.useDialogControl() 72 const {gtMobile} = useBreakpoints() 73 const {height: minHeight} = useWindowDimensions() 74 75 return ( 76 <> 77 <Button 78 label={_(msg`Find people to follow`)} 79 onPress={() => { 80 control.open() 81 logEvent('progressGuide:followDialog:open', {}) 82 }} 83 size={gtMobile ? 'small' : 'large'} 84 color="primary" 85 variant="solid"> 86 <ButtonIcon icon={PersonGroupIcon} /> 87 <ButtonText> 88 <Trans>Find people to follow</Trans> 89 </ButtonText> 90 </Button> 91 <Dialog.Outer control={control} nativeOptions={{minHeight}}> 92 <Dialog.Handle /> 93 <DialogInner guide={guide} /> 94 </Dialog.Outer> 95 </> 96 ) 97} 98 99// Fine to keep this top-level. 100let lastSelectedInterest = '' 101let lastSearchText = '' 102 103function DialogInner({guide}: {guide: Follow10ProgressGuide}) { 104 const {_} = useLingui() 105 const interestsDisplayNames = useInterestsDisplayNames() 106 const {data: preferences} = usePreferencesQuery() 107 const personalizedInterests = preferences?.interests?.tags 108 const interests = Object.keys(interestsDisplayNames) 109 .sort(boostInterests(popularInterests)) 110 .sort(boostInterests(personalizedInterests)) 111 const [selectedInterest, setSelectedInterest] = useState( 112 () => 113 lastSelectedInterest || 114 (personalizedInterests && interests.includes(personalizedInterests[0]) 115 ? personalizedInterests[0] 116 : interests[0]), 117 ) 118 const [searchText, setSearchText] = useState(lastSearchText) 119 const moderationOpts = useModerationOpts() 120 const listRef = useRef<ListMethods>(null) 121 const inputRef = useRef<TextInput>(null) 122 const [headerHeight, setHeaderHeight] = useState(0) 123 const {currentAccount} = useSession() 124 const [suggestedAccounts, setSuggestedAccounts] = useState< 125 Map<string, AppBskyActorDefs.ProfileView[]> 126 >(() => new Map()) 127 128 useEffect(() => { 129 lastSearchText = searchText 130 lastSelectedInterest = selectedInterest 131 }, [searchText, selectedInterest]) 132 133 const query = searchText || selectedInterest 134 const { 135 data: searchResults, 136 isFetching, 137 error, 138 isError, 139 hasNextPage, 140 isFetchingNextPage, 141 fetchNextPage, 142 } = useActorSearchPaginated({ 143 query, 144 }) 145 146 const hasSearchText = !!searchText 147 148 const items = useMemo(() => { 149 const results = searchResults?.pages.flatMap(r => r.actors) 150 let _items: Item[] = [] 151 const seen = new Set<string>() 152 153 if (isError) { 154 _items.push({ 155 type: 'empty', 156 key: 'empty', 157 message: _(msg`We're having network issues, try again`), 158 }) 159 } else if (results) { 160 // First pass: search results 161 for (const profile of results) { 162 if (profile.did === currentAccount?.did) continue 163 if (profile.viewer?.following) continue 164 // my sincere apologies to Jake Gold - your bio is too keyword-filled and 165 // your page-rank too high, so you're at the top of half the categories -sfn 166 if ( 167 !hasSearchText && 168 profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' && 169 // constrain to 'tech' 170 selectedInterest !== 'tech' 171 ) { 172 continue 173 } 174 seen.add(profile.did) 175 _items.push({ 176 type: 'profile', 177 // Don't share identity across tabs or typing attempts 178 key: query + ':' + profile.did, 179 profile, 180 isSuggestion: false, 181 }) 182 } 183 // Second pass: suggestions 184 _items = _items.flatMap(item => { 185 if (item.type !== 'profile') { 186 return item 187 } 188 const suggestions = suggestedAccounts.get(item.profile.did) 189 if (!suggestions) { 190 return item 191 } 192 const itemWithSuggestions = [item] 193 for (const suggested of suggestions) { 194 if (seen.has(suggested.did)) { 195 // Skip search results from previous step or already seen suggestions 196 continue 197 } 198 seen.add(suggested.did) 199 itemWithSuggestions.push({ 200 type: 'profile', 201 key: suggested.did, 202 profile: suggested, 203 isSuggestion: true, 204 }) 205 if (itemWithSuggestions.length === 1 + 3) { 206 break 207 } 208 } 209 return itemWithSuggestions 210 }) 211 } else { 212 const placeholders: Item[] = Array(10) 213 .fill(0) 214 .map((__, i) => ({ 215 type: 'placeholder', 216 key: i + '', 217 })) 218 219 _items.push(...placeholders) 220 } 221 222 return _items 223 }, [ 224 _, 225 searchResults, 226 isError, 227 currentAccount?.did, 228 hasSearchText, 229 selectedInterest, 230 suggestedAccounts, 231 query, 232 ]) 233 234 if (searchText && !isFetching && !items.length && !isError) { 235 items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) 236 } 237 238 const renderItems = useCallback( 239 ({item, index}: {item: Item; index: number}) => { 240 switch (item.type) { 241 case 'profile': { 242 return ( 243 <FollowProfileCard 244 profile={item.profile} 245 isSuggestion={item.isSuggestion} 246 moderationOpts={moderationOpts!} 247 setSuggestedAccounts={setSuggestedAccounts} 248 noBorder={index === 0} 249 /> 250 ) 251 } 252 case 'placeholder': { 253 return <ProfileCardSkeleton key={item.key} /> 254 } 255 case 'empty': { 256 return <Empty key={item.key} message={item.message} /> 257 } 258 default: 259 return null 260 } 261 }, 262 [moderationOpts], 263 ) 264 265 const onSelectTab = useCallback( 266 (interest: string) => { 267 setSelectedInterest(interest) 268 inputRef.current?.clear() 269 setSearchText('') 270 listRef.current?.scrollToOffset({ 271 offset: 0, 272 animated: false, 273 }) 274 }, 275 [setSelectedInterest, setSearchText], 276 ) 277 278 const listHeader = ( 279 <Header 280 guide={guide} 281 inputRef={inputRef} 282 listRef={listRef} 283 searchText={searchText} 284 onSelectTab={onSelectTab} 285 setHeaderHeight={setHeaderHeight} 286 setSearchText={setSearchText} 287 interests={interests} 288 selectedInterest={selectedInterest} 289 interestsDisplayNames={interestsDisplayNames} 290 /> 291 ) 292 293 const onEndReached = useCallback(async () => { 294 if (isFetchingNextPage || !hasNextPage || isError) return 295 try { 296 await fetchNextPage() 297 } catch (err) { 298 logger.error('Failed to load more people to follow', {message: err}) 299 } 300 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 301 302 return ( 303 <Dialog.InnerFlatList 304 ref={listRef} 305 data={items} 306 renderItem={renderItems} 307 ListHeaderComponent={listHeader} 308 stickyHeaderIndices={[0]} 309 keyExtractor={(item: Item) => item.key} 310 style={[ 311 a.px_0, 312 web([a.py_0, {height: '100vh', maxHeight: 600}]), 313 native({height: '100%'}), 314 ]} 315 webInnerContentContainerStyle={a.py_0} 316 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 317 keyboardDismissMode="on-drag" 318 scrollIndicatorInsets={{top: headerHeight}} 319 initialNumToRender={8} 320 maxToRenderPerBatch={8} 321 onEndReached={onEndReached} 322 itemLayoutAnimation={LinearTransition} 323 ListFooterComponent={ 324 <ListFooter 325 isFetchingNextPage={isFetchingNextPage} 326 error={cleanError(error)} 327 onRetry={fetchNextPage} 328 /> 329 } 330 /> 331 ) 332} 333 334let Header = ({ 335 guide, 336 inputRef, 337 listRef, 338 searchText, 339 onSelectTab, 340 setHeaderHeight, 341 setSearchText, 342 interests, 343 selectedInterest, 344 interestsDisplayNames, 345}: { 346 guide: Follow10ProgressGuide 347 inputRef: React.RefObject<TextInput> 348 listRef: React.RefObject<ListMethods> 349 onSelectTab: (v: string) => void 350 searchText: string 351 setHeaderHeight: (v: number) => void 352 setSearchText: (v: string) => void 353 interests: string[] 354 selectedInterest: string 355 interestsDisplayNames: Record<string, string> 356}): React.ReactNode => { 357 const t = useTheme() 358 const control = Dialog.useDialogContext() 359 return ( 360 <View 361 onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} 362 style={[ 363 a.relative, 364 web(a.pt_lg), 365 native(a.pt_4xl), 366 a.pb_xs, 367 a.border_b, 368 t.atoms.border_contrast_low, 369 t.atoms.bg, 370 ]}> 371 <HeaderTop guide={guide} /> 372 373 <View style={[web(a.pt_xs), a.pb_xs]}> 374 <SearchInput 375 inputRef={inputRef} 376 defaultValue={searchText} 377 onChangeText={text => { 378 setSearchText(text) 379 listRef.current?.scrollToOffset({offset: 0, animated: false}) 380 }} 381 onEscape={control.close} 382 /> 383 <Tabs 384 onSelectTab={onSelectTab} 385 interests={interests} 386 selectedInterest={selectedInterest} 387 hasSearchText={!!searchText} 388 interestsDisplayNames={interestsDisplayNames} 389 /> 390 </View> 391 </View> 392 ) 393} 394Header = memo(Header) 395 396function HeaderTop({guide}: {guide: Follow10ProgressGuide}) { 397 const {_} = useLingui() 398 const t = useTheme() 399 const control = Dialog.useDialogContext() 400 return ( 401 <View 402 style={[ 403 a.px_lg, 404 a.relative, 405 a.flex_row, 406 a.justify_between, 407 a.align_center, 408 ]}> 409 <Text 410 style={[ 411 a.z_10, 412 a.text_lg, 413 a.font_heavy, 414 a.leading_tight, 415 t.atoms.text_contrast_high, 416 ]}> 417 <Trans>Find people to follow</Trans> 418 </Text> 419 <View style={isWeb && {paddingRight: 36}}> 420 <ProgressGuideTask 421 current={guide.numFollows + 1} 422 total={10 + 1} 423 title={`${guide.numFollows} / 10`} 424 tabularNumsTitle 425 /> 426 </View> 427 {isWeb ? ( 428 <Button 429 label={_(msg`Close`)} 430 size="small" 431 shape="round" 432 variant={isWeb ? 'ghost' : 'solid'} 433 color="secondary" 434 style={[ 435 a.absolute, 436 a.z_20, 437 web({right: -4}), 438 native({right: 0}), 439 native({height: 32, width: 32, borderRadius: 16}), 440 ]} 441 onPress={() => control.close()}> 442 <ButtonIcon icon={X} size="md" /> 443 </Button> 444 ) : null} 445 </View> 446 ) 447} 448 449let Tabs = ({ 450 onSelectTab, 451 interests, 452 selectedInterest, 453 hasSearchText, 454 interestsDisplayNames, 455}: { 456 onSelectTab: (tab: string) => void 457 interests: string[] 458 selectedInterest: string 459 hasSearchText: boolean 460 interestsDisplayNames: Record<string, string> 461}): React.ReactNode => { 462 const listRef = useRef<ScrollView>(null) 463 const [scrollX, setScrollX] = useState(0) 464 const [totalWidth, setTotalWidth] = useState(0) 465 const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) 466 const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) 467 468 const onInitialLayout = useNonReactiveCallback(() => { 469 const index = interests.indexOf(selectedInterest) 470 scrollIntoViewIfNeeded(index) 471 }) 472 473 useEffect(() => { 474 if (tabOffsets) { 475 onInitialLayout() 476 } 477 }, [tabOffsets, onInitialLayout]) 478 479 function scrollIntoViewIfNeeded(index: number) { 480 const btnLayout = tabOffsets[index] 481 if (!btnLayout) return 482 483 const viewportLeftEdge = scrollX 484 const viewportRightEdge = scrollX + totalWidth 485 const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x 486 const shouldScrollToRightEdge = 487 viewportRightEdge < btnLayout.x + btnLayout.width 488 489 if (shouldScrollToLeftEdge) { 490 listRef.current?.scrollTo({ 491 x: btnLayout.x - tokens.space.lg, 492 animated: true, 493 }) 494 } else if (shouldScrollToRightEdge) { 495 listRef.current?.scrollTo({ 496 x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg, 497 animated: true, 498 }) 499 } 500 } 501 502 function handleSelectTab(index: number) { 503 const tab = interests[index] 504 onSelectTab(tab) 505 scrollIntoViewIfNeeded(index) 506 } 507 508 function handleTabLayout(index: number, x: number, width: number) { 509 if (!tabOffsets.length) { 510 pendingTabOffsets.current[index] = {x, width} 511 if (pendingTabOffsets.current.length === interests.length) { 512 setTabOffsets(pendingTabOffsets.current) 513 } 514 } 515 } 516 517 return ( 518 <ScrollView 519 ref={listRef} 520 horizontal 521 contentContainerStyle={[a.gap_sm, a.px_lg]} 522 showsHorizontalScrollIndicator={false} 523 decelerationRate="fast" 524 snapToOffsets={ 525 tabOffsets.length === interests.length 526 ? tabOffsets.map(o => o.x - tokens.space.xl) 527 : undefined 528 } 529 onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} 530 scrollEventThrottle={200} // big throttle 531 onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}> 532 {interests.map((interest, i) => { 533 const active = interest === selectedInterest && !hasSearchText 534 return ( 535 <Tab 536 key={interest} 537 onSelectTab={handleSelectTab} 538 active={active} 539 index={i} 540 interest={interest} 541 interestsDisplayName={interestsDisplayNames[interest]} 542 onLayout={handleTabLayout} 543 /> 544 ) 545 })} 546 </ScrollView> 547 ) 548} 549Tabs = memo(Tabs) 550 551let Tab = ({ 552 onSelectTab, 553 interest, 554 active, 555 index, 556 interestsDisplayName, 557 onLayout, 558}: { 559 onSelectTab: (index: number) => void 560 interest: string 561 active: boolean 562 index: number 563 interestsDisplayName: string 564 onLayout: (index: number, x: number, width: number) => void 565}): React.ReactNode => { 566 const {_} = useLingui() 567 const activeText = active ? _(msg` (active)`) : '' 568 return ( 569 <View 570 key={interest} 571 onLayout={e => 572 onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 573 }> 574 <Button 575 label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} 576 variant={active ? 'solid' : 'outline'} 577 color={active ? 'primary' : 'secondary'} 578 size="small" 579 onPress={() => onSelectTab(index)}> 580 <ButtonIcon icon={SearchIcon} /> 581 <ButtonText>{interestsDisplayName}</ButtonText> 582 </Button> 583 </View> 584 ) 585} 586Tab = memo(Tab) 587 588let FollowProfileCard = ({ 589 profile, 590 moderationOpts, 591 isSuggestion, 592 setSuggestedAccounts, 593 noBorder, 594}: { 595 profile: AppBskyActorDefs.ProfileView 596 moderationOpts: ModerationOpts 597 isSuggestion: boolean 598 setSuggestedAccounts: ( 599 updater: ( 600 v: Map<string, AppBskyActorDefs.ProfileView[]>, 601 ) => Map<string, AppBskyActorDefs.ProfileView[]>, 602 ) => void 603 noBorder?: boolean 604}): React.ReactNode => { 605 const [hasFollowed, setHasFollowed] = useState(false) 606 const followupSuggestion = useSuggestedFollowsByActorQuery({ 607 did: profile.did, 608 enabled: hasFollowed, 609 }) 610 const candidates = followupSuggestion.data?.suggestions 611 612 useEffect(() => { 613 // TODO: Move out of effect. 614 if (hasFollowed && candidates && candidates.length > 0) { 615 setSuggestedAccounts(suggestions => { 616 const newSuggestions = new Map(suggestions) 617 newSuggestions.set(profile.did, candidates) 618 return newSuggestions 619 }) 620 } 621 }, [hasFollowed, profile.did, candidates, setSuggestedAccounts]) 622 623 return ( 624 <LayoutAnimationConfig skipEntering={!isSuggestion}> 625 <Animated.View entering={native(ZoomInEasyDown)}> 626 <FollowProfileCardInner 627 profile={profile} 628 moderationOpts={moderationOpts} 629 onFollow={() => setHasFollowed(true)} 630 noBorder={noBorder} 631 /> 632 </Animated.View> 633 </LayoutAnimationConfig> 634 ) 635} 636FollowProfileCard = memo(FollowProfileCard) 637 638function FollowProfileCardInner({ 639 profile, 640 moderationOpts, 641 onFollow, 642 noBorder, 643}: { 644 profile: AppBskyActorDefs.ProfileView 645 moderationOpts: ModerationOpts 646 onFollow?: () => void 647 noBorder?: boolean 648}) { 649 const control = Dialog.useDialogContext() 650 const t = useTheme() 651 return ( 652 <ProfileCard.Link 653 profile={profile} 654 style={[a.flex_1]} 655 onPress={() => control.close()}> 656 {({hovered, pressed}) => ( 657 <CardOuter 658 style={[ 659 a.flex_1, 660 noBorder && a.border_t_0, 661 (hovered || pressed) && t.atoms.border_contrast_high, 662 ]}> 663 <ProfileCard.Outer> 664 <ProfileCard.Header> 665 <ProfileCard.Avatar 666 profile={profile} 667 moderationOpts={moderationOpts} 668 /> 669 <ProfileCard.NameAndHandle 670 profile={profile} 671 moderationOpts={moderationOpts} 672 /> 673 <ProfileCard.FollowButton 674 profile={profile} 675 moderationOpts={moderationOpts} 676 logContext="PostOnboardingFindFollows" 677 shape="round" 678 onPress={onFollow} 679 colorInverted 680 /> 681 </ProfileCard.Header> 682 <ProfileCard.Description profile={profile} numberOfLines={2} /> 683 </ProfileCard.Outer> 684 </CardOuter> 685 )} 686 </ProfileCard.Link> 687 ) 688} 689 690function CardOuter({ 691 children, 692 style, 693}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { 694 const t = useTheme() 695 return ( 696 <View 697 style={[ 698 a.w_full, 699 a.py_md, 700 a.px_lg, 701 a.border_t, 702 t.atoms.border_contrast_low, 703 style, 704 ]}> 705 {children} 706 </View> 707 ) 708} 709 710function SearchInput({ 711 onChangeText, 712 onEscape, 713 inputRef, 714 defaultValue, 715}: { 716 onChangeText: (text: string) => void 717 onEscape: () => void 718 inputRef: React.RefObject<TextInput> 719 defaultValue: string 720}) { 721 const t = useTheme() 722 const {_} = useLingui() 723 const { 724 state: hovered, 725 onIn: onMouseEnter, 726 onOut: onMouseLeave, 727 } = useInteractionState() 728 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 729 const interacted = hovered || focused 730 731 return ( 732 <View 733 {...web({ 734 onMouseEnter, 735 onMouseLeave, 736 })} 737 style={[a.flex_row, a.align_center, a.gap_sm, a.px_lg, a.py_xs]}> 738 <SearchIcon 739 size="md" 740 fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 741 /> 742 743 <TextInput 744 ref={inputRef} 745 placeholder={_(msg`Search by name or interest`)} 746 defaultValue={defaultValue} 747 onChangeText={onChangeText} 748 onFocus={onFocus} 749 onBlur={onBlur} 750 style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 751 placeholderTextColor={t.palette.contrast_500} 752 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 753 returnKeyType="search" 754 clearButtonMode="while-editing" 755 maxLength={50} 756 onKeyPress={({nativeEvent}) => { 757 if (nativeEvent.key === 'Escape') { 758 onEscape() 759 } 760 }} 761 autoCorrect={false} 762 autoComplete="off" 763 autoCapitalize="none" 764 accessibilityLabel={_(msg`Search profiles`)} 765 accessibilityHint={_(msg`Searches for profiles`)} 766 /> 767 </View> 768 ) 769} 770 771function ProfileCardSkeleton() { 772 const t = useTheme() 773 774 return ( 775 <View 776 style={[ 777 a.flex_1, 778 a.py_md, 779 a.px_lg, 780 a.gap_md, 781 a.align_center, 782 a.flex_row, 783 ]}> 784 <View 785 style={[ 786 a.rounded_full, 787 {width: 42, height: 42}, 788 t.atoms.bg_contrast_25, 789 ]} 790 /> 791 792 <View style={[a.flex_1, a.gap_sm]}> 793 <View 794 style={[ 795 a.rounded_xs, 796 {width: 80, height: 14}, 797 t.atoms.bg_contrast_25, 798 ]} 799 /> 800 <View 801 style={[ 802 a.rounded_xs, 803 {width: 120, height: 10}, 804 t.atoms.bg_contrast_25, 805 ]} 806 /> 807 </View> 808 </View> 809 ) 810} 811 812function Empty({message}: {message: string}) { 813 const t = useTheme() 814 return ( 815 <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 816 <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 817 {message} 818 </Text> 819 820 <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(°°) </Text> 821 </View> 822 ) 823} 824 825function boostInterests(boosts?: string[]) { 826 return (_a: string, _b: string) => { 827 const indexA = boosts?.indexOf(_a) ?? -1 828 const indexB = boosts?.indexOf(_b) ?? -1 829 const rankA = indexA === -1 ? Infinity : indexA 830 const rankB = indexB === -1 ? Infinity : indexB 831 return rankA - rankB 832 } 833}