diff --git a/src/components/IconCircle.tsx b/src/components/IconCircle.tsx index aa779e37f3..29af0496a4 100644 --- a/src/components/IconCircle.tsx +++ b/src/components/IconCircle.tsx @@ -2,13 +2,12 @@ import React from 'react' import {View} from 'react-native' import { - useTheme, atoms as a, - ViewStyleProp, - TextStyleProp, flatten, + TextStyleProp, + useTheme, + ViewStyleProp, } from '#/alf' -import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' import {Props} from '#/components/icons/common' export function IconCircle({ @@ -17,7 +16,7 @@ export function IconCircle({ style, iconStyle, }: ViewStyleProp & { - icon: typeof Growth + icon: React.ComponentType size?: Props['size'] iconStyle?: TextStyleProp['style'] }) { diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 59b8f7ed55..1b357a5612 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -31,7 +31,7 @@ const suggestedFollowsByActorQueryKey = (did: string) => [ did, ] -export function useSuggestedFollowsQuery() { +export function useSuggestedFollowsQuery(options?: {limit?: number}) { const {currentAccount} = useSession() const agent = useAgent() const moderationOpts = useModerationOpts() @@ -51,7 +51,7 @@ export function useSuggestedFollowsQuery() { const contentLangs = getContentLanguages().join(',') const res = await agent.app.bsky.actor.getSuggestions( { - limit: 25, + limit: options?.limit || 25, cursor: pageParam, }, { diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index b6daf84b38..f72c9c1c3e 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -29,13 +29,14 @@ import {MagnifyingGlassIcon} from '#/lib/icons' import {makeProfileLink} from '#/lib/routes/links' import {NavigationProp} from '#/lib/routes/types' import {augmentSearchQuery} from '#/lib/strings/helpers' -import {s} from '#/lib/styles' import {logger} from '#/logger' import {isIOS, isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorSearch} from '#/state/queries/actor-search' +import {useGetPopularFeedsQuery} from '#/state/queries/feed' +import {usePreferencesQuery} from '#/state/queries/preferences' import {useSearchPostsQuery} from '#/state/queries/search-posts' import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' @@ -47,6 +48,7 @@ import { NativeStackScreenProps, SearchTabNavigatorParams, } from 'lib/routes/types' +import {cleanError} from 'lib/strings/errors' import {useTheme} from 'lib/ThemeContext' import {Pager} from '#/view/com/pager/Pager' import {TabBar} from '#/view/com/pager/TabBar' @@ -56,10 +58,23 @@ import {Link} from '#/view/com/util/Link' import {List} from '#/view/com/util/List' import {Text} from '#/view/com/util/text/Text' import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {KNOWN_AUTHED_ONLY_FEEDS} from '#/view/screens/Feeds' import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' -import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {atoms as a} from '#/alf' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import { + FeedFeedLoadingPlaceholder, + ProfileCardFeedLoadingPlaceholder, +} from 'view/com/util/LoadingPlaceholder' +import {atoms as a, useTheme as useThemeNew} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {IconCircle} from '#/components/IconCircle' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Props as SVGIconProps} from '#/components/icons/common' +import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Loader as LoaderIcon} from '#/components/Loader' +import {Text as TextNew} from '#/components/Typography' function Loader() { const pal = usePalette('default') @@ -122,69 +137,399 @@ function EmptyState({message, error}: {message: string; error?: string}) { ) } -function useSuggestedFollows(): [ - AppBskyActorDefs.ProfileViewBasic[], - () => void, -] { - const { - data: suggestions, - hasNextPage, - isFetchingNextPage, - isError, - fetchNextPage, - } = useSuggestedFollowsQuery() +function SuggestedItemsHeader({ + title, + description, + icon, +}: { + title: string + description: string + icon: React.ComponentType +}) { + const t = useThemeNew() - const onEndReached = React.useCallback(async () => { - if (isFetchingNextPage || !hasNextPage || isError) return + return ( + + + + + {title} + + {description} + + + ) +} + +type ExploreScreenItems = + | { + type: 'header' + key: string + title: string + description: string + icon: React.ComponentType + } + | { + type: 'profile' + key: string + profile: AppBskyActorDefs.ProfileViewBasic + } + | { + type: 'feed' + key: string + feed: AppBskyFeedDefs.GeneratorView + } + | { + type: 'loadMore' + key: string + isLoadingMore: boolean + onLoadMore: () => void + } + | { + type: 'profilePlaceholder' + key: string + } + | { + type: 'feedPlaceholder' + key: string + } + | { + type: 'error' + key: string + message: string + error: string + } + +function ExploreScreen() { + const {_} = useLingui() + const t = useThemeNew() + const {hasSession} = useSession() + const {data: preferences, error: preferencesError} = usePreferencesQuery() + const { + data: profiles, + hasNextPage: hasNextProfilesPage, + isLoading: isLoadingProfiles, + isFetchingNextPage: isFetchingNextProfilesPage, + error: profilesError, + fetchNextPage: fetchNextProfilesPage, + } = useSuggestedFollowsQuery({limit: 5}) + const { + data: feeds, + hasNextPage: hasNextFeedsPage, + isLoading: isLoadingFeeds, + isFetchingNextPage: isFetchingNextFeedsPage, + error: feedsError, + fetchNextPage: fetchNextFeedsPage, + } = useGetPopularFeedsQuery({limit: 5}) + + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles + const onLoadMoreProfiles = React.useCallback(async () => { + if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) + return try { - await fetchNextPage() + await fetchNextProfilesPage() } catch (err) { logger.error('Failed to load more suggested follows', {message: err}) } - }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + }, [ + isFetchingNextProfilesPage, + hasNextProfilesPage, + profilesError, + fetchNextProfilesPage, + ]) - 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) + const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds + const onLoadMoreFeeds = React.useCallback(async () => { + if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return + try { + await fetchNextFeedsPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextFeedsPage, + hasNextFeedsPage, + feedsError, + fetchNextFeedsPage, + ]) + + const items = React.useMemo(() => { + const i: ExploreScreenItems[] = [ + { + type: 'header', + key: 'suggested-follows-header', + title: _(msg`Suggested follows`), + description: _(msg`Find new friends or interesting accounts`), + icon: ListSparkle, + }, + ] + + if (profiles) { + // 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 profiles.pages) { + for (const actor of page.actors) { + if (!seen.has(actor.did)) { + seen.add(actor.did) + i.push({ + type: 'profile', + key: actor.did, + profile: actor, + }) + } } } + + i.push({ + type: 'loadMore', + key: 'loadMoreProfiles', + isLoadingMore: isLoadingMoreProfiles, + onLoadMore: onLoadMoreProfiles, + }) + } else { + if (profilesError) { + i.push({ + type: 'error', + key: 'profilesError', + message: _(msg`Failed to load suggested follows`), + error: cleanError(profilesError), + }) + } else { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } } - } - return [items, onEndReached] -} -let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => { - const pal = usePalette('default') - const [suggestions, onEndReached] = useSuggestedFollows() + i.push({ + type: 'header', + key: 'suggested-feeds-header', + title: _(msg`Suggested feeds`), + description: _(msg`Discover new content`), + icon: ListSparkle, + }) - return suggestions.length ? ( + if (feeds && preferences) { + const filtered = feeds.pages.map(page => { + page.feeds = page.feeds.filter(feed => { + if (!hasSession && KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)) { + return false + } + const alreadySaved = Boolean( + preferences?.savedFeeds?.find(f => { + return f.value === feed.uri + }), + ) + return !alreadySaved + }) + + return page + }) + + // 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 filtered) { + for (const feed of page.feeds) { + if (!seen.has(feed.uri)) { + seen.add(feed.uri) + i.push({ + type: 'feed', + key: feed.uri, + feed, + }) + } + } + } + + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + i.push({ + type: 'loadMore', + key: 'loadMoreFeeds', + isLoadingMore: isLoadingMoreFeeds, + onLoadMore: onLoadMoreFeeds, + }) + } + } else { + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) + } + } + + return i + }, [ + _, + hasSession, + profiles, + feeds, + preferences, + onLoadMoreFeeds, + onLoadMoreProfiles, + isLoadingMoreProfiles, + isLoadingMoreFeeds, + profilesError, + feedsError, + preferencesError, + ]) + + const renderItem = React.useCallback( + ({item}: {item: ExploreScreenItems}) => { + switch (item.type) { + case 'header': { + return ( + + ) + } + case 'profile': { + return + } + case 'feed': { + return ( + + ) + } + case 'loadMore': { + return ( + + + + ) + } + case 'profilePlaceholder': { + return + } + case 'feedPlaceholder': { + return + } + case 'error': { + return ( + + + + + + {item.message} + + + {item.error} + + + + + ) + } + } + }, + [_, t, hasSession], + ) + + return ( } - keyExtractor={item => item.did} + data={items} + renderItem={renderItem} + keyExtractor={item => item.key} // @ts-ignore web only -prf desktopFixedHeight contentContainerStyle={{paddingBottom: 200}} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" - onEndReached={onEndReached} - onEndReachedThreshold={2} /> - ) : ( - - - - ) } -SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows) type SearchResultSlice = | { @@ -408,26 +753,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { ))} ) : hasSession ? ( - - - - Suggested Follows - - - - - + ) : (