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 {useModerationOpts} from '#/state/preferences/moderation-opts'
14import {useActorSearch} from '#/state/queries/actor-search'
15import {usePreferencesQuery} from '#/state/queries/preferences'
16import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
17import {useSession} from '#/state/session'
18import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
19import {type ListMethods} from '#/view/com/util/List'
20import {
21 atoms as a,
22 native,
23 useBreakpoints,
24 useTheme,
25 type ViewStyleProp,
26 web,
27} from '#/alf'
28import {Button, ButtonIcon, ButtonText} from '#/components/Button'
29import * as Dialog from '#/components/Dialog'
30import {useInteractionState} from '#/components/hooks/useInteractionState'
31import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
32import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
33import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
34import {boostInterests, InterestTabs} from '#/components/InterestTabs'
35import * as ProfileCard from '#/components/ProfileCard'
36import {Text} from '#/components/Typography'
37import {useAnalytics} from '#/analytics'
38import {IS_WEB} from '#/env'
39import type * as bsky from '#/types/bsky'
40import {ProgressGuideTask} from './Task'
41
42type Item =
43 | {
44 type: 'profile'
45 key: string
46 profile: bsky.profile.AnyProfileView
47 }
48 | {
49 type: 'empty'
50 key: string
51 message: string
52 }
53 | {
54 type: 'placeholder'
55 key: string
56 }
57 | {
58 type: 'error'
59 key: string
60 }
61
62export function FollowDialog({
63 guide,
64 showArrow,
65}: {
66 guide: Follow10ProgressGuide
67 showArrow?: boolean
68}) {
69 const ax = useAnalytics()
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 ax.metric('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 ax = useAnalytics()
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 ax.metric('suggestedUser:seen', {
276 logContext: 'ProgressGuide',
277 recId: undefined,
278 position: position !== -1 ? position : 0,
279 suggestedDid: item.profile.did,
280 category: selectedInterestRef.current,
281 })
282 }
283 }
284 }
285 },
286 ).current
287 const viewabilityConfig = useRef({
288 itemVisiblePercentThreshold: 50,
289 }).current
290
291 const onSelectTab = useCallback(
292 (interest: string) => {
293 setSelectedInterest(interest)
294 inputRef.current?.clear()
295 setSearchText('')
296 listRef.current?.scrollToOffset({
297 offset: 0,
298 animated: false,
299 })
300 },
301 [setSelectedInterest, setSearchText],
302 )
303
304 const listHeader = (
305 <Header
306 guide={guide}
307 inputRef={inputRef}
308 listRef={listRef}
309 searchText={searchText}
310 onSelectTab={onSelectTab}
311 setHeaderHeight={setHeaderHeight}
312 setSearchText={setSearchText}
313 interests={interests}
314 selectedInterest={selectedInterest}
315 interestsDisplayNames={interestsDisplayNames}
316 />
317 )
318
319 return (
320 <Dialog.InnerFlatList
321 ref={listRef}
322 data={items}
323 renderItem={renderItems}
324 ListHeaderComponent={listHeader}
325 stickyHeaderIndices={[0]}
326 keyExtractor={(item: Item) => item.key}
327 style={[
328 a.px_0,
329 web([a.py_0, {height: '100vh', maxHeight: 600}]),
330 native({height: '100%'}),
331 ]}
332 webInnerContentContainerStyle={a.py_0}
333 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
334 keyboardDismissMode="on-drag"
335 scrollIndicatorInsets={{top: headerHeight}}
336 initialNumToRender={8}
337 maxToRenderPerBatch={8}
338 onViewableItemsChanged={onViewableItemsChanged}
339 viewabilityConfig={viewabilityConfig}
340 />
341 )
342}
343
344let Header = ({
345 guide,
346 inputRef,
347 listRef,
348 searchText,
349 onSelectTab,
350 setHeaderHeight,
351 setSearchText,
352 interests,
353 selectedInterest,
354 interestsDisplayNames,
355}: {
356 guide?: Follow10ProgressGuide
357 inputRef: React.RefObject<TextInput | null>
358 listRef: React.RefObject<ListMethods | null>
359 onSelectTab: (v: string) => void
360 searchText: string
361 setHeaderHeight: (v: number) => void
362 setSearchText: (v: string) => void
363 interests: string[]
364 selectedInterest: string
365 interestsDisplayNames: Record<string, string>
366}): React.ReactNode => {
367 const t = useTheme()
368 const control = Dialog.useDialogContext()
369 return (
370 <View
371 onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}
372 style={[
373 a.relative,
374 web(a.pt_lg),
375 native(a.pt_4xl),
376 a.pb_xs,
377 a.border_b,
378 t.atoms.border_contrast_low,
379 t.atoms.bg,
380 ]}>
381 <HeaderTop guide={guide} />
382
383 <View style={[web(a.pt_xs), a.pb_xs]}>
384 <SearchInput
385 inputRef={inputRef}
386 defaultValue={searchText}
387 onChangeText={text => {
388 setSearchText(text)
389 listRef.current?.scrollToOffset({offset: 0, animated: false})
390 }}
391 onEscape={control.close}
392 />
393 <InterestTabs
394 onSelectTab={onSelectTab}
395 interests={interests}
396 selectedInterest={selectedInterest}
397 disabled={!!searchText}
398 interestsDisplayNames={interestsDisplayNames}
399 TabComponent={Tab}
400 />
401 </View>
402 </View>
403 )
404}
405Header = memo(Header)
406
407function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) {
408 const {_} = useLingui()
409 const t = useTheme()
410 const control = Dialog.useDialogContext()
411 return (
412 <View
413 style={[
414 a.px_lg,
415 a.relative,
416 a.flex_row,
417 a.justify_between,
418 a.align_center,
419 ]}>
420 <Text
421 style={[
422 a.z_10,
423 a.text_lg,
424 a.font_bold,
425 a.leading_tight,
426 t.atoms.text_contrast_high,
427 ]}>
428 <Trans>Find people to follow</Trans>
429 </Text>
430 {guide && (
431 <View style={IS_WEB && {paddingRight: 36}}>
432 <ProgressGuideTask
433 current={guide.numFollows + 1}
434 total={10 + 1}
435 title={`${guide.numFollows} / 10`}
436 tabularNumsTitle
437 />
438 </View>
439 )}
440 {IS_WEB ? (
441 <Button
442 label={_(msg`Close`)}
443 size="small"
444 shape="round"
445 variant={IS_WEB ? 'ghost' : 'solid'}
446 color="secondary"
447 style={[
448 a.absolute,
449 a.z_20,
450 web({right: 8}),
451 native({right: 0}),
452 native({height: 32, width: 32, borderRadius: 16}),
453 ]}
454 onPress={() => control.close()}>
455 <ButtonIcon icon={X} size="md" />
456 </Button>
457 ) : null}
458 </View>
459 )
460}
461
462let Tab = ({
463 onSelectTab,
464 interest,
465 active,
466 index,
467 interestsDisplayName,
468 onLayout,
469}: {
470 onSelectTab: (index: number) => void
471 interest: string
472 active: boolean
473 index: number
474 interestsDisplayName: string
475 onLayout: (index: number, x: number, width: number) => void
476}): React.ReactNode => {
477 const t = useTheme()
478 const {_} = useLingui()
479 const label = active
480 ? _(
481 msg({
482 message: `Search for "${interestsDisplayName}" (active)`,
483 comment:
484 '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.',
485 }),
486 )
487 : _(
488 msg({
489 message: `Search for "${interestsDisplayName}"`,
490 comment:
491 '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.',
492 }),
493 )
494 return (
495 <View
496 key={interest}
497 onLayout={e =>
498 onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
499 }>
500 <Button label={label} onPress={() => onSelectTab(index)}>
501 {({hovered, pressed}) => (
502 <View
503 style={[
504 a.rounded_full,
505 a.px_lg,
506 a.py_sm,
507 a.border,
508 active || hovered || pressed
509 ? [
510 t.atoms.bg_contrast_25,
511 {borderColor: t.atoms.bg_contrast_25.backgroundColor},
512 ]
513 : [t.atoms.bg, t.atoms.border_contrast_low],
514 ]}>
515 <Text
516 style={[
517 a.font_medium,
518 active || hovered || pressed
519 ? t.atoms.text
520 : t.atoms.text_contrast_medium,
521 ]}>
522 {interestsDisplayName}
523 </Text>
524 </View>
525 )}
526 </Button>
527 </View>
528 )
529}
530Tab = memo(Tab)
531
532let FollowProfileCard = ({
533 profile,
534 moderationOpts,
535 noBorder,
536}: {
537 profile: bsky.profile.AnyProfileView
538 moderationOpts: ModerationOpts
539 noBorder?: boolean
540}): React.ReactNode => {
541 return (
542 <FollowProfileCardInner
543 profile={profile}
544 moderationOpts={moderationOpts}
545 noBorder={noBorder}
546 />
547 )
548}
549FollowProfileCard = memo(FollowProfileCard)
550
551function FollowProfileCardInner({
552 profile,
553 moderationOpts,
554 onFollow,
555 noBorder,
556}: {
557 profile: bsky.profile.AnyProfileView
558 moderationOpts: ModerationOpts
559 onFollow?: () => void
560 noBorder?: boolean
561}) {
562 const control = Dialog.useDialogContext()
563 const t = useTheme()
564 return (
565 <ProfileCard.Link
566 profile={profile}
567 style={[a.flex_1]}
568 onPress={() => control.close()}>
569 {({hovered, pressed}) => (
570 <CardOuter
571 style={[
572 a.flex_1,
573 noBorder && a.border_t_0,
574 (hovered || pressed) && t.atoms.bg_contrast_25,
575 ]}>
576 <ProfileCard.Outer>
577 <ProfileCard.Header>
578 <ProfileCard.Avatar
579 disabledPreview={!IS_WEB}
580 profile={profile}
581 moderationOpts={moderationOpts}
582 />
583 <ProfileCard.NameAndHandle
584 profile={profile}
585 moderationOpts={moderationOpts}
586 />
587 <ProfileCard.FollowButton
588 profile={profile}
589 moderationOpts={moderationOpts}
590 logContext="PostOnboardingFindFollows"
591 shape="round"
592 onPress={onFollow}
593 colorInverted
594 />
595 </ProfileCard.Header>
596 <ProfileCard.Description profile={profile} numberOfLines={2} />
597 </ProfileCard.Outer>
598 </CardOuter>
599 )}
600 </ProfileCard.Link>
601 )
602}
603
604function CardOuter({
605 children,
606 style,
607}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
608 const t = useTheme()
609 return (
610 <View
611 style={[
612 a.w_full,
613 a.py_md,
614 a.px_lg,
615 a.border_t,
616 t.atoms.border_contrast_low,
617 style,
618 ]}>
619 {children}
620 </View>
621 )
622}
623
624function SearchInput({
625 onChangeText,
626 onEscape,
627 inputRef,
628 defaultValue,
629}: {
630 onChangeText: (text: string) => void
631 onEscape: () => void
632 inputRef: React.RefObject<TextInput | null>
633 defaultValue: string
634}) {
635 const t = useTheme()
636 const {_} = useLingui()
637 const {
638 state: hovered,
639 onIn: onMouseEnter,
640 onOut: onMouseLeave,
641 } = useInteractionState()
642 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
643 const interacted = hovered || focused
644
645 return (
646 <View
647 {...web({
648 onMouseEnter,
649 onMouseLeave,
650 })}
651 style={[a.flex_row, a.align_center, a.gap_sm, a.px_lg, a.py_xs]}>
652 <SearchIcon
653 size="md"
654 fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
655 />
656
657 <TextInput
658 ref={inputRef}
659 placeholder={_(msg`Search by name or interest`)}
660 defaultValue={defaultValue}
661 onChangeText={onChangeText}
662 onFocus={onFocus}
663 onBlur={onBlur}
664 style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
665 placeholderTextColor={t.palette.contrast_500}
666 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
667 returnKeyType="search"
668 clearButtonMode="while-editing"
669 maxLength={50}
670 onKeyPress={({nativeEvent}) => {
671 if (nativeEvent.key === 'Escape') {
672 onEscape()
673 }
674 }}
675 autoCorrect={false}
676 autoComplete="off"
677 autoCapitalize="none"
678 accessibilityLabel={_(msg`Search profiles`)}
679 accessibilityHint={_(msg`Searches for profiles`)}
680 />
681 </View>
682 )
683}
684
685function ProfileCardSkeleton() {
686 const t = useTheme()
687
688 return (
689 <View
690 style={[
691 a.flex_1,
692 a.py_md,
693 a.px_lg,
694 a.gap_md,
695 a.align_center,
696 a.flex_row,
697 ]}>
698 <View
699 style={[
700 a.rounded_full,
701 {width: 42, height: 42},
702 t.atoms.bg_contrast_25,
703 ]}
704 />
705
706 <View style={[a.flex_1, a.gap_sm]}>
707 <View
708 style={[
709 a.rounded_xs,
710 {width: 80, height: 14},
711 t.atoms.bg_contrast_25,
712 ]}
713 />
714 <View
715 style={[
716 a.rounded_xs,
717 {width: 120, height: 10},
718 t.atoms.bg_contrast_25,
719 ]}
720 />
721 </View>
722 </View>
723 )
724}
725
726function Empty({message}: {message: string}) {
727 const t = useTheme()
728 return (
729 <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
730 <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
731 {message}
732 </Text>
733
734 <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
735 </View>
736 )
737}