diff --git a/assets/icons/arrowBottom_stroke2_corner0_rounded.svg b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..5f4a11e0
--- /dev/null
+++ b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/icons/Arrow.tsx b/src/components/icons/Arrow.tsx
index eb753e54..d6fb635e 100644
--- a/src/components/icons/Arrow.tsx
+++ b/src/components/icons/Arrow.tsx
@@ -7,3 +7,7 @@ export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
})
+
+export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
+})
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index fed23f5b..2981b41b 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -190,8 +190,10 @@ export const KNOWN_AUTHED_ONLY_FEEDS = [
type GetPopularFeedsOptions = {limit?: number}
-export function createGetPopularFeedsQueryKey(...args: any[]) {
- return ['getPopularFeeds', ...args]
+export function createGetPopularFeedsQueryKey(
+ options?: GetPopularFeedsOptions,
+) {
+ return ['getPopularFeeds', options]
}
export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
@@ -299,6 +301,34 @@ export function useSearchPopularFeedsMutation() {
})
}
+const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch'
+export const createPopularFeedsSearchQueryKey = (query: string) => [
+ popularFeedsSearchQueryKeyRoot,
+ query,
+]
+
+export function usePopularFeedsSearch({
+ query,
+ enabled,
+}: {
+ query: string
+ enabled?: boolean
+}) {
+ const agent = useAgent()
+ return useQuery({
+ enabled,
+ queryKey: createPopularFeedsSearchQueryKey(query),
+ queryFn: async () => {
+ const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
+ limit: 10,
+ query: query,
+ })
+
+ return res.data.feeds
+ },
+ })
+}
+
export type SavedFeedSourceInfo = FeedSourceInfo & {
savedFeed: AppBskyActorDefs.SavedFeed
}
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 59b8f7ed..40251d43 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -23,7 +23,10 @@ import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../preferences/moderation-opts'
const suggestedFollowsQueryKeyRoot = 'suggested-follows'
-const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot]
+const suggestedFollowsQueryKey = (options?: SuggestedFollowsOptions) => [
+ suggestedFollowsQueryKeyRoot,
+ options,
+]
const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor'
const suggestedFollowsByActorQueryKey = (did: string) => [
@@ -31,7 +34,9 @@ const suggestedFollowsByActorQueryKey = (did: string) => [
did,
]
-export function useSuggestedFollowsQuery() {
+type SuggestedFollowsOptions = {limit?: number}
+
+export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
const {currentAccount} = useSession()
const agent = useAgent()
const moderationOpts = useModerationOpts()
@@ -46,12 +51,12 @@ export function useSuggestedFollowsQuery() {
>({
enabled: !!moderationOpts && !!preferences,
staleTime: STALE.HOURS.ONE,
- queryKey: suggestedFollowsQueryKey,
+ queryKey: suggestedFollowsQueryKey(options),
queryFn: async ({pageParam}) => {
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/Explore.tsx b/src/view/screens/Search/Explore.tsx
new file mode 100644
index 00000000..f6e99883
--- /dev/null
+++ b/src/view/screens/Search/Explore.tsx
@@ -0,0 +1,556 @@
+import React from 'react'
+import {View} from 'react-native'
+import {
+ AppBskyActorDefs,
+ AppBskyFeedDefs,
+ moderateProfile,
+ ModerationDecision,
+ ModerationOpts,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useGetPopularFeedsQuery} from '#/state/queries/feed'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {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 {List} from '#/view/com/util/List'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
+import {
+ FeedFeedLoadingPlaceholder,
+ ProfileCardFeedLoadingPlaceholder,
+} from 'view/com/util/LoadingPlaceholder'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {Button} from '#/components/Button'
+import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow'
+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 {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+function SuggestedItemsHeader({
+ title,
+ description,
+ style,
+ icon: Icon,
+}: {
+ title: string
+ description: string
+ icon: React.ComponentType
+} & ViewStyleProp) {
+ const t = useTheme()
+
+ return (
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ )
+}
+
+type LoadMoreItems =
+ | {
+ type: 'profile'
+ key: string
+ avatar: string
+ moderation: ModerationDecision
+ }
+ | {
+ type: 'feed'
+ key: string
+ avatar: string
+ moderation: undefined
+ }
+
+function LoadMore({
+ item,
+ moderationOpts,
+}: {
+ item: ExploreScreenItems & {type: 'loadMore'}
+ moderationOpts?: ModerationOpts
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const items = React.useMemo(() => {
+ return item.items
+ .map(_item => {
+ if (_item.type === 'profile') {
+ return {
+ type: 'profile',
+ key: _item.profile.did,
+ avatar: _item.profile.avatar,
+ moderation: moderateProfile(_item.profile, moderationOpts!),
+ }
+ } else if (_item.type === 'feed') {
+ return {
+ type: 'feed',
+ key: _item.feed.uri,
+ avatar: _item.feed.avatar,
+ moderation: undefined,
+ }
+ }
+ return undefined
+ })
+ .filter(Boolean) as LoadMoreItems[]
+ }, [item.items, moderationOpts])
+ const type = items[0].type
+
+ return (
+
+
+
+ )
+}
+
+type ExploreScreenItems =
+ | {
+ type: 'header'
+ key: string
+ title: string
+ description: string
+ style?: ViewStyleProp['style']
+ 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
+ items: ExploreScreenItems[]
+ }
+ | {
+ type: 'profilePlaceholder'
+ key: string
+ }
+ | {
+ type: 'feedPlaceholder'
+ key: string
+ }
+ | {
+ type: 'error'
+ key: string
+ message: string
+ error: string
+ }
+
+export function Explore() {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {hasSession} = useSession()
+ const {data: preferences, error: preferencesError} = usePreferencesQuery()
+ const moderationOpts = useModerationOpts()
+ const {
+ data: profiles,
+ hasNextPage: hasNextProfilesPage,
+ isLoading: isLoadingProfiles,
+ isFetchingNextPage: isFetchingNextProfilesPage,
+ error: profilesError,
+ fetchNextPage: fetchNextProfilesPage,
+ } = useSuggestedFollowsQuery({limit: 3})
+ const {
+ data: feeds,
+ hasNextPage: hasNextFeedsPage,
+ isLoading: isLoadingFeeds,
+ isFetchingNextPage: isFetchingNextFeedsPage,
+ error: feedsError,
+ fetchNextPage: fetchNextFeedsPage,
+ } = useGetPopularFeedsQuery({limit: 3})
+
+ const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
+ const onLoadMoreProfiles = React.useCallback(async () => {
+ if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
+ return
+ try {
+ await fetchNextProfilesPage()
+ } catch (err) {
+ logger.error('Failed to load more suggested follows', {message: err})
+ }
+ }, [
+ isFetchingNextProfilesPage,
+ hasNextProfilesPage,
+ profilesError,
+ fetchNextProfilesPage,
+ ])
+
+ 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 accounts`),
+ description: _(
+ msg`Follow more accounts to get connected to your interests and build your network.`,
+ ),
+ icon: Person,
+ },
+ ]
+
+ 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,
+ items: i.filter(item => item.type === 'profile').slice(-3),
+ })
+ } 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'})
+ }
+ }
+
+ i.push({
+ type: 'header',
+ key: 'suggested-feeds-header',
+ title: _(msg`Discover new feeds`),
+ description: _(
+ msg`Custom feeds built by the community bring you new experiences and help you find the content you love.`,
+ ),
+ style: [a.pt_5xl],
+ icon: ListSparkle,
+ })
+
+ if (feeds && preferences) {
+ // 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 feeds.pages) {
+ 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,
+ items: i.filter(item => item.type === 'feed').slice(-3),
+ })
+ }
+ } 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
+ }, [
+ _,
+ 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, moderationOpts],
+ )
+
+ return (
+ item.key}
+ // @ts-ignore web only -prf
+ desktopFixedHeight
+ contentContainerStyle={{paddingBottom: 200}}
+ keyboardShouldPersistTaps="handled"
+ keyboardDismissMode="on-drag"
+ />
+ )
+}
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index b6daf84b..f1b0301d 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -29,15 +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 {usePopularFeedsSearch} from '#/state/queries/feed'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
-import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import {useSetDrawerOpen} from '#/state/shell'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
@@ -56,8 +55,9 @@ 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 {Explore} from '#/view/screens/Search/Explore'
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
-import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {atoms as a} from '#/alf'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
@@ -122,70 +122,6 @@ function EmptyState({message, error}: {message: string; error?: string}) {
)
}
-function useSuggestedFollows(): [
- 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]
-}
-
-let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => {
- const pal = usePalette('default')
- 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}
- />
- ) : (
-
-
-
-
- )
-}
-SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows)
-
type SearchResultSlice =
| {
type: 'post'
@@ -342,6 +278,50 @@ let SearchScreenUserResults = ({
}
SearchScreenUserResults = React.memo(SearchScreenUserResults)
+let SearchScreenFeedsResults = ({
+ query,
+ active,
+}: {
+ query: string
+ active: boolean
+}): React.ReactNode => {
+ const {_} = useLingui()
+ const {hasSession} = useSession()
+
+ const {data: results, isFetched} = usePopularFeedsSearch({
+ query,
+ enabled: active,
+ })
+
+ return isFetched && results ? (
+ <>
+ {results.length ? (
+ (
+
+ )}
+ keyExtractor={item => item.did}
+ // @ts-ignore web only -prf
+ desktopFixedHeight
+ contentContainerStyle={{paddingBottom: 100}}
+ />
+ ) : (
+
+ )}
+ >
+ ) : (
+
+ )
+}
+SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults)
+
let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
@@ -389,6 +369,12 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
),
},
+ {
+ title: _(msg`Feeds`),
+ component: (
+
+ ),
+ },
]
}, [_, query, activeTab])
@@ -408,26 +394,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
))}
) : hasSession ? (
-
-
-
- Suggested Follows
-
-
-
-
-
+
) : (