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