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