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