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