diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 0d3de89697..7c1ef55f57 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -176,7 +176,7 @@ export function useGetPopularFeedsQuery() { queryKey: useGetPopularFeedsQueryKey, queryFn: async ({pageParam}) => { const res = await getAgent().app.bsky.unspecced.getPopularFeedGenerators({ - limit: 10, + limit: 30, cursor: pageParam, }) return res.data diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 45b855835f..1a1d0752b4 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -21,9 +21,7 @@ import {HITSLOP_10} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import {MagnifyingGlassIcon} from '#/lib/icons' import {NavigationProp} from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' import {augmentSearchQuery} from '#/lib/strings/helpers' -import {s} from '#/lib/styles' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' @@ -31,10 +29,6 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {useActorSearch} from '#/state/queries/actor-search' import {useModerationOpts} from '#/state/queries/preferences' import {useSearchPostsQuery} from '#/state/queries/search-posts' -import { - useGetSuggestedFollowersByActor, - useSuggestedFollowsQuery, -} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -55,8 +49,8 @@ import { SearchLinkCard, SearchProfileCard, } from '#/view/shell/desktop/Search' -import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {atoms as a} from '#/alf' +import {Suggestions} from './Suggestions' function Loader() { const pal = usePalette('default') @@ -119,124 +113,6 @@ function EmptyState({message, error}: {message: string; error?: string}) { ) } -function useSuggestedFollowsV1(): [ - AppBskyActorDefs.ProfileViewBasic[], - () => void, -] { - const {currentAccount} = useSession() - const [suggestions, setSuggestions] = React.useState< - AppBskyActorDefs.ProfileViewBasic[] - >([]) - const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor() - - React.useEffect(() => { - async function getSuggestions() { - const friends = await getSuggestedFollowsByActor( - currentAccount!.did, - ).then(friendsRes => friendsRes.suggestions) - - if (!friends) return // :( - - const friendsOfFriends = new Map< - string, - AppBskyActorDefs.ProfileViewBasic - >() - - await Promise.all( - friends.slice(0, 4).map(friend => - getSuggestedFollowsByActor(friend.did).then(foafsRes => { - for (const user of foafsRes.suggestions) { - if (user.associated?.labeler) continue - friendsOfFriends.set(user.did, user) - } - }), - ), - ) - - setSuggestions(Array.from(friendsOfFriends.values())) - } - - try { - getSuggestions() - } catch (e) { - logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, { - message: e, - }) - } - }, [currentAccount, setSuggestions, getSuggestedFollowsByActor]) - - return [suggestions, () => {}] -} - -function useSuggestedFollowsV2(): [ - AppBskyActorDefs.ProfileViewBasic[], - () => void, -] { - const { - data: suggestions, - hasNextPage, - isFetchingNextPage, - isError, - fetchNextPage, - } = useSuggestedFollowsQuery() - - const onEndReached = React.useCallback(async () => { - if (isFetchingNextPage || !hasNextPage || isError) return - try { - await fetchNextPage() - } catch (err) { - logger.error('Failed to load more suggested follows', {message: err}) - } - }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) - - const items: AppBskyActorDefs.ProfileViewBasic[] = [] - if (suggestions) { - // Currently the responses contain duplicate items. - // Needs to be fixed on backend, but let's dedupe to be safe. - let seen = new Set() - for (const page of suggestions.pages) { - for (const actor of page.actors) { - if (!seen.has(actor.did)) { - seen.add(actor.did) - items.push(actor) - } - } - } - } - return [items, onEndReached] -} - -function SearchScreenSuggestedFollows() { - const pal = usePalette('default') - const gate = useGate() - const useSuggestedFollows = gate('use_new_suggestions_endpoint') - ? // Conditional hook call here is *only* OK because useGate() - // result won't change until a remount. - useSuggestedFollowsV2 - : useSuggestedFollowsV1 - const [suggestions, onEndReached] = useSuggestedFollows() - - return suggestions.length ? ( - } - keyExtractor={item => item.did} - // @ts-ignore web only -prf - desktopFixedHeight - contentContainerStyle={{paddingBottom: 200}} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - onEndReached={onEndReached} - onEndReachedThreshold={2} - /> - ) : ( - - - - - ) -} - type SearchResultSlice = | { type: 'post' @@ -456,24 +332,7 @@ export function SearchScreenInner({query}: {query?: string}) { ) : hasSession ? ( - - - Suggested Follows - - - - + ) : ( diff --git a/src/view/screens/Search/Suggestions.tsx b/src/view/screens/Search/Suggestions.tsx new file mode 100644 index 0000000000..4203227678 --- /dev/null +++ b/src/view/screens/Search/Suggestions.tsx @@ -0,0 +1,378 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {useGate} from '#/lib/statsig/statsig' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useGetPopularFeedsQuery} from '#/state/queries/feed' +import { + useGetSuggestedFollowersByActor, + useSuggestedFollowsQuery, +} from '#/state/queries/suggested-follows' +import {useSession} from '#/state/session' +import {cleanError} from 'lib/strings/errors' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {List} from 'view/com/util/List' +import { + FeedFeedLoadingPlaceholder, + ProfileCardFeedLoadingPlaceholder, +} from 'view/com/util/LoadingPlaceholder' +import {Text} from 'view/com/util/text/Text' +import {atoms as a, useTheme} from '#/alf' +import {IconCircle} from '#/components/IconCircle' +import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' +import {Person_Stroke2_Corner0_Rounded} from '#/components/icons/Person' + +type FlatlistItem = + | { + type: 'error' + key: string + error: string + } + | {type: 'suggestedFollowsHeader'; key: string} + | {type: 'suggestedFollowsLoading'; key: string} + | { + type: 'suggestedFollow' + key: string + profile: AppBskyActorDefs.ProfileViewBasic + } + | { + type: 'popularFeedsHeader' + key: string + } + | { + type: 'popularFeedsLoading' + key: string + } + | { + type: 'popularFeedsNoResults' + key: string + } + | { + type: 'popularFeed' + key: string + feedUri: string + } + +// HACK +// the protocol doesn't yet tell us which feeds are personalized +// this list is used to filter out feed recommendations from logged out users +// for the ones we know need it +// -prf +const KNOWN_AUTHED_ONLY_FEEDS = [ + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app + 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed + 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed + 'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow + 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky + 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz + 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why +] + +export function Suggestions() { + const gate = useGate() + const useSuggestedFollows = gate('use_new_suggestions_endpoint') + ? // Conditional hook call here is *only* OK because useGate() + // result won't change until a remount. + useSuggestedFollowsV2 + : useSuggestedFollowsV1 + + const [isPTR, setIsPTR] = React.useState(false) + + const { + data: popularFeeds, + isFetching: isPopularFeedsFetching, + error: popularFeedsError, + refetch: refetchPopularFeeds, + } = useGetPopularFeedsQuery() + const [suggestedFollows, refetchSuggestedFollows] = useSuggestedFollows() + const {hasSession} = useSession() + + /** + * A search query is present. We may not have search results yet. + */ + const onPullToRefresh = React.useCallback(async () => { + setIsPTR(true) + await Promise.all([ + refetchPopularFeeds().catch(_e => undefined), + refetchSuggestedFollows().catch(_e => undefined), + ]) + setIsPTR(false) + }, [setIsPTR, refetchPopularFeeds, refetchSuggestedFollows]) + + const items = React.useMemo(() => { + let items: FlatlistItem[] = [] + + items.push({ + key: 'suggestedFollowsHeader', + type: 'suggestedFollowsHeader', + }) + if (!suggestedFollows.length) { + items.push({ + key: 'suggestedFollowsLoading', + type: 'suggestedFollowsLoading', + }) + } else { + items = items.concat( + suggestedFollows.map(follow => ({ + key: `suggestedFollow:${follow.did}`, + type: 'suggestedFollow', + profile: follow, + })), + ) + } + + items.push({ + key: 'popularFeedsHeader', + type: 'popularFeedsHeader', + }) + + if (popularFeedsError) { + items.push({ + key: 'popularFeedsError', + type: 'error', + error: cleanError(popularFeedsError?.toString()), + }) + } else { + if (isPopularFeedsFetching && !popularFeeds?.pages) { + items.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if ( + !popularFeeds?.pages || + popularFeeds?.pages[0]?.feeds?.length === 0 + ) { + items.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { + for (const page of popularFeeds.pages || []) { + items = items.concat( + page.feeds + .filter(feed => { + if ( + !hasSession && + KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri) + ) { + return false + } + return true + }) + .map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + })), + ) + } + } + } + } + + return items + }, [ + hasSession, + suggestedFollows, + popularFeeds, + isPopularFeedsFetching, + popularFeedsError, + ]) + + const renderItem = React.useCallback( + ({item}: {item: FlatlistItem}) => { + if (item.type === 'error') { + return + } else if (item.type === 'suggestedFollowsHeader') { + return + } else if (item.type === 'suggestedFollowsLoading') { + return + } else if (item.type === 'suggestedFollow') { + return + } else if (item.type === 'popularFeedsHeader') { + return + } else if (item.type === 'popularFeedsLoading') { + return + } else if (item.type === 'popularFeed') { + return ( + + ) + } + return null + }, + [hasSession], + ) + + return ( + item.key} + contentContainerStyle={{paddingBottom: 200}} + renderItem={renderItem} + refreshing={isPTR} + onRefresh={onPullToRefresh} + initialNumToRender={10} + // @ts-ignore our .web version only -prf + desktopFixedHeight + scrollIndicatorInsets={{right: 1}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + /> + ) +} + +function SuggestedFollowsHeader() { + const t = useTheme() + + return ( + + + + + Suggested Accounts + + + + Follow more accounts to get connected to your interests and build + your network. + + + + + ) +} + +function SuggestedFeedsHeader() { + const t = useTheme() + + return ( + + + + + Discover New Feeds + + + + Custom feeds built by the community bring you new experiences and + help you find the content you love. + + + + + ) +} + +function useSuggestedFollowsV1(): [ + AppBskyActorDefs.ProfileViewBasic[], + () => void, +] { + const {currentAccount} = useSession() + const [suggestions, setSuggestions] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor() + + React.useEffect(() => { + async function getSuggestions() { + const friends = await getSuggestedFollowsByActor( + currentAccount!.did, + ).then(friendsRes => friendsRes.suggestions) + + if (!friends) return // :( + + const friendsOfFriends = new Map< + string, + AppBskyActorDefs.ProfileViewBasic + >() + + await Promise.all( + friends.slice(0, 4).map(friend => + getSuggestedFollowsByActor(friend.did).then(foafsRes => { + for (const user of foafsRes.suggestions) { + if (user.associated?.labeler) continue + friendsOfFriends.set(user.did, user) + } + }), + ), + ) + + setSuggestions(Array.from(friendsOfFriends.values())) + } + + try { + getSuggestions() + } catch (e) { + logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, { + message: e, + }) + } + }, [currentAccount, setSuggestions, getSuggestedFollowsByActor]) + + return [suggestions, async () => {}] +} + +function useSuggestedFollowsV2(): [ + AppBskyActorDefs.ProfileViewBasic[], + () => void, +] { + const {data: suggestions, refetch} = useSuggestedFollowsQuery() + + const items: AppBskyActorDefs.ProfileViewBasic[] = [] + if (suggestions) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + for (const page of suggestions.pages) { + for (const actor of page.actors) { + if (!seen.has(actor.did)) { + seen.add(actor.did) + items.push(actor) + } + } + } + } + return [items, refetch] +}