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