import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' import {TextInput, useWindowDimensions, View} from 'react-native' import {type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {logEvent} from '#/lib/statsig/statsig' import {isWeb} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorSearchPaginated} from '#/state/queries/actor-search' import {usePreferencesQuery} from '#/state/queries/preferences' import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery' import {useSession} from '#/state/session' import {type Follow10ProgressGuide} from '#/state/shell/progress-guide' import {type ListMethods} from '#/view/com/util/List' import { popularInterests, useInterestsDisplayNames, } from '#/screens/Onboarding/state' import { atoms as a, native, useBreakpoints, useTheme, type ViewStyleProp, web, } from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {boostInterests, InterestTabs} from '#/components/InterestTabs' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' import {ProgressGuideTask} from './Task' type Item = | { type: 'profile' key: string profile: bsky.profile.AnyProfileView } | { type: 'empty' key: string message: string } | { type: 'placeholder' key: string } | { type: 'error' key: string } export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) { const {_} = useLingui() const control = Dialog.useDialogControl() const {gtMobile} = useBreakpoints() const {height: minHeight} = useWindowDimensions() return ( <> ) } // Fine to keep this top-level. let lastSelectedInterest = '' let lastSearchText = '' function DialogInner({guide}: {guide: Follow10ProgressGuide}) { const {_} = useLingui() const interestsDisplayNames = useInterestsDisplayNames() const {data: preferences} = usePreferencesQuery() const personalizedInterests = preferences?.interests?.tags const interests = Object.keys(interestsDisplayNames) .sort(boostInterests(popularInterests)) .sort(boostInterests(personalizedInterests)) const [selectedInterest, setSelectedInterest] = useState( () => lastSelectedInterest || (personalizedInterests && interests.includes(personalizedInterests[0]) ? personalizedInterests[0] : interests[0]), ) const [searchText, setSearchText] = useState(lastSearchText) const moderationOpts = useModerationOpts() const listRef = useRef(null) const inputRef = useRef(null) const [headerHeight, setHeaderHeight] = useState(0) const {currentAccount} = useSession() useEffect(() => { lastSearchText = searchText lastSelectedInterest = selectedInterest }, [searchText, selectedInterest]) const { data: suggestions, isFetching: isFetchingSuggestions, error: suggestionsError, } = useGetSuggestedUsersQuery({ category: selectedInterest, limit: 50, }) const { data: searchResults, isFetching: isFetchingSearchResults, error: searchResultsError, isError: isSearchResultsError, } = useActorSearchPaginated({ enabled: !!searchText, query: searchText, }) const hasSearchText = !!searchText const resultsKey = searchText || selectedInterest const items = useMemo(() => { const results = hasSearchText ? searchResults?.pages.flatMap(p => p.actors) : suggestions?.actors let _items: Item[] = [] if (isFetchingSuggestions || isFetchingSearchResults) { const placeholders: Item[] = Array(10) .fill(0) .map((__, i) => ({ type: 'placeholder', key: i + '', })) _items.push(...placeholders) } else if ( (hasSearchText && searchResultsError) || (!hasSearchText && suggestionsError) || !results?.length ) { _items.push({ type: 'empty', key: 'empty', message: _(msg`We're having network issues, try again`), }) } else { const seen = new Set() for (const profile of results) { if (seen.has(profile.did)) continue if (profile.did === currentAccount?.did) continue if (profile.viewer?.following) continue seen.add(profile.did) _items.push({ type: 'profile', // Don't share identity across tabs or typing attempts key: resultsKey + ':' + profile.did, profile, }) } } return _items }, [ _, suggestions, suggestionsError, isFetchingSuggestions, searchResults, searchResultsError, isFetchingSearchResults, currentAccount?.did, hasSearchText, resultsKey, ]) if ( searchText && !isFetchingSearchResults && !items.length && !isSearchResultsError ) { items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) } const renderItems = useCallback( ({item, index}: {item: Item; index: number}) => { switch (item.type) { case 'profile': { return ( ) } case 'placeholder': { return } case 'empty': { return } default: return null } }, [moderationOpts], ) const onSelectTab = useCallback( (interest: string) => { setSelectedInterest(interest) inputRef.current?.clear() setSearchText('') listRef.current?.scrollToOffset({ offset: 0, animated: false, }) }, [setSelectedInterest, setSearchText], ) const listHeader = (
) return ( item.key} style={[ a.px_0, web([a.py_0, {height: '100vh', maxHeight: 600}]), native({height: '100%'}), ]} webInnerContentContainerStyle={a.py_0} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} keyboardDismissMode="on-drag" scrollIndicatorInsets={{top: headerHeight}} initialNumToRender={8} maxToRenderPerBatch={8} /> ) } let Header = ({ guide, inputRef, listRef, searchText, onSelectTab, setHeaderHeight, setSearchText, interests, selectedInterest, interestsDisplayNames, }: { guide: Follow10ProgressGuide inputRef: React.RefObject listRef: React.RefObject onSelectTab: (v: string) => void searchText: string setHeaderHeight: (v: number) => void setSearchText: (v: string) => void interests: string[] selectedInterest: string interestsDisplayNames: Record }): React.ReactNode => { const t = useTheme() const control = Dialog.useDialogContext() return ( setHeaderHeight(evt.nativeEvent.layout.height)} style={[ a.relative, web(a.pt_lg), native(a.pt_4xl), a.pb_xs, a.border_b, t.atoms.border_contrast_low, t.atoms.bg, ]}> { setSearchText(text) listRef.current?.scrollToOffset({offset: 0, animated: false}) }} onEscape={control.close} /> ) } Header = memo(Header) function HeaderTop({guide}: {guide: Follow10ProgressGuide}) { const {_} = useLingui() const t = useTheme() const control = Dialog.useDialogContext() return ( Find people to follow {isWeb ? ( ) : null} ) } let Tab = ({ onSelectTab, interest, active, index, interestsDisplayName, onLayout, }: { onSelectTab: (index: number) => void interest: string active: boolean index: number interestsDisplayName: string onLayout: (index: number, x: number, width: number) => void }): React.ReactNode => { const t = useTheme() const {_} = useLingui() const label = active ? _( msg({ message: `Search for "${interestsDisplayName}" (active)`, comment: '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.', }), ) : _( msg({ message: `Search for "${interestsDisplayName}`, comment: '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.', }), ) return ( onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) }> ) } Tab = memo(Tab) let FollowProfileCard = ({ profile, moderationOpts, noBorder, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts noBorder?: boolean }): React.ReactNode => { return ( ) } FollowProfileCard = memo(FollowProfileCard) function FollowProfileCardInner({ profile, moderationOpts, onFollow, noBorder, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onFollow?: () => void noBorder?: boolean }) { const control = Dialog.useDialogContext() const t = useTheme() return ( control.close()}> {({hovered, pressed}) => ( )} ) } function CardOuter({ children, style, }: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { const t = useTheme() return ( {children} ) } function SearchInput({ onChangeText, onEscape, inputRef, defaultValue, }: { onChangeText: (text: string) => void onEscape: () => void inputRef: React.RefObject defaultValue: string }) { const t = useTheme() const {_} = useLingui() const { state: hovered, onIn: onMouseEnter, onOut: onMouseLeave, } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const interacted = hovered || focused return ( { if (nativeEvent.key === 'Escape') { onEscape() } }} autoCorrect={false} autoComplete="off" autoCapitalize="none" accessibilityLabel={_(msg`Search profiles`)} accessibilityHint={_(msg`Searches for profiles`)} /> ) } function ProfileCardSkeleton() { const t = useTheme() return ( ) } function Empty({message}: {message: string}) { const t = useTheme() return ( {message} (╯°□°)╯︵ ┻━┻ ) }