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