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 {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}