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