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