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