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