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