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