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