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