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