import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native' import Animated, { LayoutAnimationConfig, LinearTransition, ZoomInEasyDown, } from 'react-native-reanimated' import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' 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 {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import {Follow10ProgressGuide} from '#/state/shell/progress-guide' import {ListMethods} from '#/view/com/util/List' import { popularInterests, useInterestsDisplayNames, } from '#/screens/Onboarding/state' import { atoms as a, native, tokens, useBreakpoints, useTheme, 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 * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import {ListFooter} from '../Lists' import {ProgressGuideTask} from './Task' type Item = | { type: 'profile' key: string profile: AppBskyActorDefs.ProfileView isSuggestion: boolean } | { 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() const [suggestedAccounts, setSuggestedAccounts] = useState< Map >(() => new Map()) useEffect(() => { lastSearchText = searchText lastSelectedInterest = selectedInterest }, [searchText, selectedInterest]) const query = searchText || selectedInterest const { data: searchResults, isFetching, error, isError, hasNextPage, isFetchingNextPage, fetchNextPage, } = useActorSearchPaginated({ query, }) const hasSearchText = !!searchText const items = useMemo(() => { const results = searchResults?.pages.flatMap(r => r.actors) let _items: Item[] = [] const seen = new Set() if (isError) { _items.push({ type: 'empty', key: 'empty', message: _(msg`We're having network issues, try again`), }) } else if (results) { // First pass: search results for (const profile of results) { if (profile.did === currentAccount?.did) continue if (profile.viewer?.following) continue // my sincere apologies to Jake Gold - your bio is too keyword-filled and // your page-rank too high, so you're at the top of half the categories -sfn if ( !hasSearchText && profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' && // constrain to 'tech' selectedInterest !== 'tech' ) { continue } seen.add(profile.did) _items.push({ type: 'profile', // Don't share identity across tabs or typing attempts key: query + ':' + profile.did, profile, isSuggestion: false, }) } // Second pass: suggestions _items = _items.flatMap(item => { if (item.type !== 'profile') { return item } const suggestions = suggestedAccounts.get(item.profile.did) if (!suggestions) { return item } const itemWithSuggestions = [item] for (const suggested of suggestions) { if (seen.has(suggested.did)) { // Skip search results from previous step or already seen suggestions continue } seen.add(suggested.did) itemWithSuggestions.push({ type: 'profile', key: suggested.did, profile: suggested, isSuggestion: true, }) if (itemWithSuggestions.length === 1 + 3) { break } } return itemWithSuggestions }) } else { const placeholders: Item[] = Array(10) .fill(0) .map((__, i) => ({ type: 'placeholder', key: i + '', })) _items.push(...placeholders) } return _items }, [ _, searchResults, isError, currentAccount?.did, hasSearchText, selectedInterest, suggestedAccounts, query, ]) if (searchText && !isFetching && !items.length && !isError) { 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 = (
) const onEndReached = useCallback(async () => { if (isFetchingNextPage || !hasNextPage || isError) return try { await fetchNextPage() } catch (err) { logger.error('Failed to load more people to follow', {message: err}) } }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 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} onEndReached={onEndReached} itemLayoutAnimation={LinearTransition} ListFooterComponent={ } /> ) } 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 Tabs = ({ onSelectTab, interests, selectedInterest, hasSearchText, interestsDisplayNames, }: { onSelectTab: (tab: string) => void interests: string[] selectedInterest: string hasSearchText: boolean interestsDisplayNames: Record }): React.ReactNode => { const listRef = useRef(null) const [scrollX, setScrollX] = useState(0) const [totalWidth, setTotalWidth] = useState(0) const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) const onInitialLayout = useNonReactiveCallback(() => { const index = interests.indexOf(selectedInterest) scrollIntoViewIfNeeded(index) }) useEffect(() => { if (tabOffsets) { onInitialLayout() } }, [tabOffsets, onInitialLayout]) function scrollIntoViewIfNeeded(index: number) { const btnLayout = tabOffsets[index] if (!btnLayout) return const viewportLeftEdge = scrollX const viewportRightEdge = scrollX + totalWidth const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x const shouldScrollToRightEdge = viewportRightEdge < btnLayout.x + btnLayout.width if (shouldScrollToLeftEdge) { listRef.current?.scrollTo({ x: btnLayout.x - tokens.space.lg, animated: true, }) } else if (shouldScrollToRightEdge) { listRef.current?.scrollTo({ x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg, animated: true, }) } } function handleSelectTab(index: number) { const tab = interests[index] onSelectTab(tab) scrollIntoViewIfNeeded(index) } function handleTabLayout(index: number, x: number, width: number) { if (!tabOffsets.length) { pendingTabOffsets.current[index] = {x, width} if (pendingTabOffsets.current.length === interests.length) { setTabOffsets(pendingTabOffsets.current) } } } return ( o.x - tokens.space.xl) : undefined } onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} scrollEventThrottle={200} // big throttle onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}> {interests.map((interest, i) => { const active = interest === selectedInterest && !hasSearchText return ( ) })} ) } Tabs = memo(Tabs) 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 {_} = useLingui() const activeText = active ? _(msg` (active)`) : '' return ( onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) }> ) } Tab = memo(Tab) let FollowProfileCard = ({ profile, moderationOpts, isSuggestion, setSuggestedAccounts, noBorder, }: { profile: AppBskyActorDefs.ProfileView moderationOpts: ModerationOpts isSuggestion: boolean setSuggestedAccounts: ( updater: ( v: Map, ) => Map, ) => void noBorder?: boolean }): React.ReactNode => { const [hasFollowed, setHasFollowed] = useState(false) const followupSuggestion = useSuggestedFollowsByActorQuery({ did: profile.did, enabled: hasFollowed, }) const candidates = followupSuggestion.data?.suggestions useEffect(() => { // TODO: Move out of effect. if (hasFollowed && candidates && candidates.length > 0) { setSuggestedAccounts(suggestions => { const newSuggestions = new Map(suggestions) newSuggestions.set(profile.did, candidates) return newSuggestions }) } }, [hasFollowed, profile.did, candidates, setSuggestedAccounts]) return ( setHasFollowed(true)} noBorder={noBorder} /> ) } FollowProfileCard = memo(FollowProfileCard) function FollowProfileCardInner({ profile, moderationOpts, onFollow, noBorder, }: { profile: AppBskyActorDefs.ProfileView 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`Search profiles`)} /> ) } function ProfileCardSkeleton() { const t = useTheme() return ( ) } function Empty({message}: {message: string}) { const t = useTheme() return ( {message} (╯°□°)╯︵ ┻━┻ ) } function boostInterests(boosts?: string[]) { return (_a: string, _b: string) => { const indexA = boosts?.indexOf(_a) ?? -1 const indexB = boosts?.indexOf(_b) ?? -1 const rankA = indexA === -1 ? Infinity : indexA const rankB = indexB === -1 ? Infinity : indexB return rankA - rankB } }